Compare commits

...

54 Commits

Author SHA1 Message Date
Mauricio Siu
4a271c11e7 Merge pull request #4239 from Dokploy/feat/resend-verification-email-on-signin
feat: resend verification email on sign-in and improve template
2026-04-17 14:02:01 -06:00
Mauricio Siu
fda367b2c5 fix: update logger configuration to disable in production environment
Change the logger's disabled property to be dependent on the NODE_ENV variable, ensuring logging is disabled in production for improved performance and security.
2026-04-17 14:01:46 -06:00
Mauricio Siu
ea1238b1d1 feat: resend verification email on sign-in and improve email template
- Add `sendOnSignIn: true` to emailVerification config so unverified users
  receive a new verification email when they attempt to sign in
- Create styled verification email template matching the invoice email design
- Extract `sendVerificationEmail` helper to keep auth.ts clean
- Show friendly message on login when email is not verified
2026-04-17 13:59:50 -06:00
Mauricio Siu
b060f80932 feat: add no tags message to tag selector component
Enhance the TagSelector component to display a message when no tags are created, prompting users to add tags. This improves user experience by providing clear feedback in the UI.
2026-04-16 12:21:17 -06:00
Mauricio Siu
04b9f56333 chore: enhance version synchronization workflow for MCP and CLI repositories
Update the GitHub Actions workflow to include regeneration of tools from the latest OpenAPI specification and ensure the latest openapi.json is copied to the CLI repository. This improves the consistency and accuracy of the versioning and API documentation across both repositories.
2026-04-15 20:55:37 -06:00
Mauricio Siu
599b97da51 feat: add version synchronization workflow for MCP and CLI repositories
Implement a GitHub Actions workflow to automatically sync the version from the Dokploy repository to the MCP and CLI repositories upon release. This includes cloning the repositories, updating the package.json version, and committing the changes with relevant metadata, ensuring consistent versioning across platforms.
2026-04-15 18:50:54 -06:00
Mauricio Siu
415298fddb feat: add OpenAPI sync to MCP and CLI repositories
Implement workflows to sync the OpenAPI specification to both the MCP and CLI repositories. This includes cloning the repositories, updating the openapi.json file, and committing the changes with relevant metadata. The process ensures that the OpenAPI documentation is consistently updated across multiple platforms.
2026-04-15 18:32:20 -06:00
Mauricio Siu
ddff8b9de7 feat: add container networks view to dashboard
Integrate a new component, ShowContainerNetworks, to display network details for each container in the dashboard. This includes a dialog that shows network information such as IP address, gateway, and MAC address, enhancing the container management capabilities.
2026-04-13 22:04:46 -06:00
Mauricio Siu
90f97912a4 Merge pull request #4221 from Dokploy/feat/container-view-mounts
feat: add view mounts, config, and terminal to container actions
2026-04-13 21:58:20 -06:00
Mauricio Siu
9af745ce67 feat: add view mounts, view config, and terminal to container actions
Add a new "View Mounts" action to the container dropdown that displays
volume and bind mounts in a formatted table (type, source, destination,
mode, read/write). Also add "View Config" and "Terminal" actions to the
compose containers tab which previously only had logs and lifecycle actions.
2026-04-13 21:56:53 -06:00
Mauricio Siu
d99f2cd460 Merge pull request #4216 from nizepart/fix/server-ip-override-on-user-creation
fix: prevent serverIp from being overwritten on every user registration
2026-04-13 20:59:26 -06:00
Mauricio Siu
d234558822 Merge pull request #4219 from Dokploy/feat/service-cards-context-menu
feat: add context menu to service cards
2026-04-13 20:52:22 -06:00
Mauricio Siu
7f25ddca44 fix: add loading feedback and invalidation to context menu actions
Use toast.promise for loading/success/error states and invalidate
environment query after actions complete to update service status.
2026-04-13 20:51:22 -06:00
Mauricio Siu
638b3dd546 feat: add context menu to service cards
Right-click on service cards to quickly Start, Deploy, Stop, or Delete
a service without navigating into it. Uses shadcn/ui ContextMenu
component built on @radix-ui/react-context-menu. Delete action shows
a confirmation dialog. LibSQL services are excluded since they lack
standard mutation endpoints.
2026-04-13 20:48:17 -06:00
Mauricio Siu
1a8fd8396d Merge pull request #4218 from Dokploy/feat/compose-containers-tab
feat: add containers tab to compose services
2026-04-13 20:36:19 -06:00
Mauricio Siu
385850f354 fix: update audit action for container termination
Change the audit action from "kill" to "stop" for the containerKill function to better reflect the operation being performed. This aligns the logging with the intended action and improves clarity in audit records.
2026-04-13 20:36:04 -06:00
Mauricio Siu
a48306a2c6 fix: address PR review feedback
- Use "kill" audit action for killContainer instead of "stop"
- Pass undefined instead of empty string for optional serverId
2026-04-13 20:34:06 -06:00
Mauricio Siu
89737e7b65 refactor: remove duplicate import of ShowComposeContainers component
Eliminate redundant import statement for ShowComposeContainers in the compose service page, streamlining the code and improving readability.
2026-04-13 20:32:11 -06:00
Mauricio Siu
00c708483e fix: use service.read permission for compose container actions
Change restartContainer, startContainer, stopContainer, and killContainer
endpoints to use service.read instead of docker.read so members with
access to the compose can use container lifecycle actions.
2026-04-13 20:31:58 -06:00
autofix-ci[bot]
ddf570a807 [autofix.ci] apply automated fixes 2026-04-14 02:15:37 +00:00
Mauricio Siu
f8eb2ba4ba feat: add containers tab to compose services
Add a Containers tab to the compose service page that lists all
containers with their state, status, and container ID. Each container
has a dropdown menu with lifecycle actions: View Logs, Restart, Start,
Stop, and Kill.

- Add containerStart, containerStop, containerKill functions to docker service
- Add corresponding tRPC procedures with server ownership checks and audit logging
- Update containerRestart to support remote servers via serverId
- Create ShowComposeContainers component with table view and action menu
- Add Containers tab between Deployments and Backups, gated by docker.read permission
2026-04-13 20:11:21 -06:00
Трапезин Андрей Александрович
9f07f8e9e1 fix: prevent serverIp from being overwritten on every user registration 2026-04-13 19:57:31 +03:00
Mauricio Siu
3cefa43a21 Merge pull request #4031 from difagume/style/deployments-remove-max-w-8xl
style(dashboard): remove max-width constraint from deployments card
2026-04-12 14:00:46 -06:00
autofix-ci[bot]
0941ec9f3e [autofix.ci] apply automated fixes 2026-04-12 20:00:08 +00:00
Mauricio Siu
879218a8b1 Merge branch 'canary' into style/deployments-remove-max-w-8xl 2026-04-12 13:59:24 -06:00
Mauricio Siu
d6124aae81 refactor: clean up code formatting and improve error handling in job scheduling
- Simplified code formatting for better readability in various components.
- Updated job scheduling functions to handle errors gracefully, ensuring that failures in scheduling do not disrupt the overall process.
- Enhanced logging for better traceability of job scheduling issues.

These changes improve code maintainability and user experience by providing clearer error messages and more organized code structure.
2026-04-11 10:04:29 -06:00
Mauricio Siu
f404b231a6 Merge pull request #4198 from Dokploy/feat/billing-cloud-improvements
Feat/billing cloud improvements
2026-04-11 00:50:50 -06:00
Mauricio Siu
7a986e5fb3 feat: enhance Stripe integration with customer updates and billing requirements
- Added customer update fields for automatic name and address handling during subscription creation.
- Enabled billing address collection and tax ID collection for improved compliance and billing accuracy.

These changes enhance the Stripe payment process by ensuring necessary customer information is captured and managed effectively.
2026-04-11 00:25:07 -06:00
Mauricio Siu
9687ed0d83 feat: add invoice notification settings and email notifications for payments
- Introduced a new feature allowing users to enable or disable invoice email notifications in the billing settings.
- Implemented email notifications for successful invoice payments and payment failures, enhancing user communication regarding billing.
- Updated the database schema to include a new column for storing user preferences on invoice notifications.
- Added corresponding email templates for invoice notifications and payment failure alerts.

These changes improve user experience by keeping users informed about their billing status and actions required.
2026-04-11 00:18:23 -06:00
Mauricio Siu
b4c57b6326 Merge pull request #4190 from Dokploy/fix/traefik-strip-path-middleware-order
fix: correct stripPath and addPrefix middleware order
2026-04-09 17:40:40 -06:00
Mauricio Siu
f8eb3c2b76 fix: swap stripPrefix and addPrefix middleware order in Traefik domain config
When both stripPath and internalPath are configured, addPrefix was pushed
before stripPrefix causing incorrect path rewriting (e.g. /app/v2/public/api
instead of /app/v2/api). Traefik executes middlewares in array order, so
stripPrefix must come first.

Closes #4061
2026-04-09 17:35:42 -06:00
Mauricio Siu
a30617d85d Merge pull request #4189 from Dokploy/fix/monitoring-cpu-value-type-guard
fix: add runtime type guard for cpu.value in monitoring tab
2026-04-09 17:25:44 -06:00
Mauricio Siu
b079cbd427 fix: add runtime type guard for cpu.value in monitoring tab
Closes #4062
2026-04-09 17:25:04 -06:00
Mauricio Siu
aeda19db8a Merge pull request #4188 from Dokploy/fix/compose-project-name-orphan-containers
fix: inject COMPOSE_PROJECT_NAME to prevent orphaned containers on redeploy
2026-04-09 17:09:52 -06:00
Mauricio Siu
cb64482649 fix: inject COMPOSE_PROJECT_NAME to prevent orphaned containers on redeploy
When users set a custom docker compose command without the -p flag,
Docker Compose defaults to using the directory name (code) as the
project name. If the custom command is later removed, Dokploy uses
-p appName, creating a new stack while the old one remains running.

Injecting COMPOSE_PROJECT_NAME=appName into the .env ensures the
project name is always consistent regardless of the command used.

Closes #4019
2026-04-09 17:06:09 -06:00
Mauricio Siu
f4cae5f775 Merge pull request #4185 from Dokploy/fix/compose-delete-orphaned-containers
fix: prevent orphaned containers when deleting compose services
2026-04-09 16:26:31 -06:00
Mauricio Siu
825e6b654c fix: prevent orphaned containers when deleting compose services
Commands were chained with && so if the project directory was missing,
cd would fail and docker compose down would never execute — leaving
containers and volumes running. Use semicolons to run each command
independently, matching the existing stack deletion pattern.

Closes #4064
2026-04-09 16:25:36 -06:00
Mauricio Siu
c1b19376a9 Merge pull request #4183 from Dokploy/feat/ai-improvements
feat: add AI log analysis component and integrate into deployment views
2026-04-09 11:45:07 -06:00
Mauricio Siu
6c3578a475 feat: enhance AnalyzeLogs component with AI provider configuration prompt
- Updated the AnalyzeLogs component to display a message and button for configuring AI providers when none are available, improving user guidance.
- Added a link to the settings page for easy access to AI provider configuration.
- Integrated new icon for the configuration button to enhance UI clarity.

These changes improve the user experience by ensuring users are informed about the need to set up AI providers for log analysis.
2026-04-09 11:44:55 -06:00
Mauricio Siu
b8db120432 refactor: enhance getContainerLogs function to support app name or ID
- Updated the `getContainerLogs` function to accept either an application name or container ID, improving flexibility in log retrieval.
- Simplified the command execution logic by consolidating the remote and local execution paths.
- Added a new parameter to directly use container IDs, streamlining the process for users.

These changes enhance the usability of the logging feature, allowing for more efficient access to container logs.
2026-04-09 11:41:01 -06:00
Mauricio Siu
7c10610a5a feat: add readLogs procedure to multiple routers for container log retrieval
- Implemented a new `readLogs` procedure across various routers (application, compose, libsql, mariadb, mongo, mysql, postgres, redis) to enable users to retrieve logs from containers.
- Each procedure includes input validation for parameters such as `tail`, `since`, and `search`, ensuring robust access control and authorization checks.
- Enhanced the `getContainerLogs` service to support fetching logs from both Docker containers and services, improving the logging capabilities of the application.

This feature enhances observability and troubleshooting for users by providing direct access to container logs.
2026-04-09 11:40:02 -06:00
Mauricio Siu
8d8658a478 fix: update Z.AI API URL and enhance AI router access control
- Corrected the API URL for Z.AI by removing the trailing slash.
- Modified the AI router mutation to include context and added access control to ensure users can only access their organization's AI settings.

These changes improve the accuracy of the API integration and enhance security by enforcing organizational access restrictions.
2026-04-09 11:27:19 -06:00
autofix-ci[bot]
fbde5be02c [autofix.ci] apply automated fixes 2026-04-09 17:20:44 +00:00
Mauricio Siu
090c0226ed feat: add AI log analysis component and integrate into deployment views
- Introduced the AnalyzeLogs component for analyzing logs using AI, allowing users to select AI providers and view analysis results.
- Integrated AnalyzeLogs into the ShowDeployment and DockerLogsId components, enabling log analysis for both build and runtime contexts.
- Updated the AI router to include a new endpoint for log analysis, which processes logs and returns structured insights.
- Enhanced the AI provider selection logic to support new providers, including Z.AI and MiniMax.

This feature enhances the user experience by providing actionable insights from logs, improving troubleshooting and operational efficiency.
2026-04-09 09:27:31 -06:00
Mauricio Siu
4a1b42899b Merge pull request #4168 from Dokploy/fix/ssh-key-member-access
fix: allow members to use SSH keys for deployments without full access
2026-04-05 18:17:10 -06:00
Mauricio Siu
343514d4eb fix: allow members to use SSH keys for deployments without full SSH key access
Add allForApps endpoint that returns only sshKeyId and name using protectedProcedure instead of withPermission, so members can select SSH keys in the git provider dropdown without needing access to the SSH Keys management panel.

closes #4069
2026-04-05 18:12:13 -06:00
Mauricio Siu
36067618f4 Merge pull request #4167 from Dokploy/fix/server-listen-before-init
fix: start server listener before initialization to prevent healthcheck failures
2026-04-05 17:37:13 -06:00
Mauricio Siu
cc74f9e38c fix: start server listener before initialization to prevent healthcheck failures
Move server.listen() before the initialization block so the HTTP server
is already responding when Docker healthchecks begin. Previously, slow
operations like SMTP timeouts in sendDokployRestartNotifications() could
block the server from listening, causing healthcheck failures and
container restarts.

Closes #4049
2026-04-05 17:36:18 -06:00
Mauricio Siu
df7e1da776 Merge pull request #4112 from manalkaff/fix/mongodb-connection-url-missing-auth-params
fix: add authSource and directConnection params to MongoDB connection URLs
2026-04-05 17:21:53 -06:00
Mauricio Siu
df9aa50ece Merge pull request #4166 from Dokploy/feat/docker-cleanup-tooltip
feat: add tooltip to Daily Docker Cleanup toggle
2026-04-05 17:20:09 -06:00
manalkaff
d9b2b48643 fix: make directConnection conditional on replicaSets config 2026-03-30 20:58:43 +08:00
manalkaff
148c91bf5e fix: add authSource and directConnection params to MongoDB connection URLs
Fixes #4105 - MongoDB external and internal connection URLs were missing
required query parameters causing authentication failures.

Added ?authSource=admin&directConnection=true to both connection strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:50:55 +08:00
Diego Fabricio
4ef8c94340 Apply suggestion from @greptile-apps[bot]
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-18 21:21:47 -05:00
Diego Fabricio
ff369c9d3a style(dashboard): remove max-width constraint from deployments card
- Deleted max-w-8xl class to allow card width to adapt freely
2026-03-18 21:09:08 -05:00
61 changed files with 11549 additions and 424 deletions

View File

@@ -68,3 +68,45 @@ jobs:
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
View 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 }}"

View File

@@ -424,6 +424,26 @@ test("Custom entrypoint with internalPath adds addprefix middleware", async () =
expect(router.entryPoints).toEqual(["custom"]);
});
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
path: "/public",
stripPath: true,
internalPath: "/app/v2",
},
"web",
);
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
expect(stripIndex).toBeGreaterThanOrEqual(0);
expect(addIndex).toBeGreaterThanOrEqual(0);
expect(stripIndex).toBeLessThan(addIndex);
});
test("Custom entrypoint with https and custom cert resolver", async () => {
const router = await createRouterConfig(
baseApp,

View File

@@ -1,6 +1,7 @@
import copy from "copy-to-clipboard";
import { Check, Copy, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -165,6 +166,7 @@ export const ShowDeployment = ({
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<AnalyzeLogs logs={filteredLogs} context="build" />
{serverId && (
<div className="flex items-center space-x-2">

View File

@@ -55,7 +55,7 @@ interface Props {
export const SaveGitProvider = ({ applicationId }: Props) => {
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 { mutateAsync, isPending } =

View File

@@ -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>
);
};

View File

@@ -55,7 +55,7 @@ interface Props {
export const SaveGitProviderCompose = ({ composeId }: Props) => {
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 { mutateAsync, isPending } = api.compose.update.useMutation();

View 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>
);
}

View File

@@ -12,6 +12,7 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { AnalyzeLogs } from "./analyze-logs";
import { LineCountFilter } from "./line-count-filter";
import { SinceLogsFilter, type TimeFilter } from "./since-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" />
Download logs
</Button>
<AnalyzeLogs logs={filteredLogs} context="runtime" />
</div>
</div>
{isPaused && (

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -10,6 +10,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { ShowContainerMounts } from "../mounts/show-container-mounts";
import { ShowContainerNetworks } from "../networks/show-container-networks";
import { RemoveContainerDialog } from "../remove/remove-container";
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
import { UploadFileModal } from "../upload/upload-file-modal";
@@ -123,6 +125,14 @@ export const columns: ColumnDef<Container>[] = [
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={container.serverId || ""}

View File

@@ -82,7 +82,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const buildConnectionUrl = () => {
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());

View File

@@ -62,7 +62,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<Label>Internal Connection URL </Label>
<ToggleVisibilityInput
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>

View File

@@ -220,11 +220,11 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
Used: {currentData.cpu.value}
Used: {String(currentData.cpu.value ?? "0%")}
</span>
<Progress
value={Number.parseInt(
currentData.cpu.value.replace("%", ""),
String(currentData.cpu.value ?? "0%").replace("%", ""),
10,
)}
className="w-[100%]"

View File

@@ -298,7 +298,19 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 w-full justify-end">
<Button
onClick={stepper.prev}
onClick={() => {
if (
stepper.current.id === "variant" &&
templateInfo.details
) {
setTemplateInfo((prev) => ({
...prev,
details: null,
}));
return;
}
stepper.prev();
}}
disabled={stepper.isFirst}
variant="secondary"
>

View File

@@ -2,6 +2,7 @@ import { loadStripe } from "@stripe/stripe-js";
import clsx from "clsx";
import {
AlertTriangle,
Bell,
CheckIcon,
CreditCard,
FileText,
@@ -25,7 +26,17 @@ import {
CardTitle,
} from "@/components/ui/card";
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 { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
@@ -90,6 +101,8 @@ export const ShowBilling = () => {
api.stripe.createCustomerPortalSession.useMutation();
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
api.stripe.upgradeSubscription.useMutation();
const { mutateAsync: updateInvoiceNotifications } =
api.stripe.updateInvoiceNotifications.useMutation();
const utils = api.useUtils();
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
@@ -151,14 +164,66 @@ export const ShowBilling = () => {
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and invoices
</CardDescription>
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<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>
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">

View File

@@ -1,6 +1,13 @@
"use client";
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 { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -37,10 +44,34 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
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({
name: z.string().min(1, { message: "Name is required" }),
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 {
data: models,
isPending: isLoadingServerModels,
isFetching: isLoadingServerModels,
error: modelsError,
} = api.ai.getModels.useQuery(
{
@@ -172,6 +203,34 @@ export const HandleAi = ({ aiId }: Props) => {
<AlertBlock type="error">{modelsError.message}</AlertBlock>
)}
<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
control={form.control}
name="name"
@@ -253,101 +312,129 @@ export const HandleAi = ({ aiId }: Props) => {
</span>
)}
{!isLoadingServerModels && !models?.length && (
<span className="text-sm text-muted-foreground">
No models available
</span>
)}
<FormField
control={form.control}
name="model"
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 && (
<FormField
control={form.control}
name="model"
render={({ field }) => {
const selectedModel = models.find(
(m) => m.id === field.value,
);
const filteredModels = models.filter((model) =>
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
);
const displayModels =
field.value &&
!filteredModels.find((m) => m.id === field.value) &&
selectedModel
? [selectedModel, ...filteredModels]
: filteredModels;
// Ensure selected model is always in the filtered list
const displayModels =
field.value &&
!filteredModels.find((m) => m.id === field.value) &&
selectedModel
? [selectedModel, ...filteredModels]
: filteredModels;
return (
<FormItem>
<FormLabel>Model</FormLabel>
<Popover
open={modelPopoverOpen}
onOpenChange={setModelPopoverOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
return (
<FormItem>
<FormLabel>Model</FormLabel>
<div className="flex gap-2">
<div className="flex-1">
{hasModels ? (
<Popover
open={modelPopoverOpen}
onOpenChange={setModelPopoverOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{field.value
? (selectedModel?.id ?? field.value)
: "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
? (selectedModel?.id ?? field.value)
: "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">
<Command>
<CommandInput
placeholder="Search models..."
value={modelSearch}
onValueChange={setModelSearch}
<Command>
<CommandInput
placeholder="Search or type a custom model..."
value={modelSearch}
onValueChange={setModelSearch}
/>
<CommandList>
<CommandEmpty>
{modelSearch ? (
<button
type="button"
className="w-full cursor-pointer px-2 py-1.5 text-left text-sm hover:bg-accent"
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>
<CommandEmpty>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>
<FormDescription>
Select an AI model to use
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
</FormControl>
)}
</div>
</div>
<FormDescription>
Select a model from the list or type a custom model name
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
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}>
{aiId ? "Update" : "Create"}
</Button>
@@ -383,3 +475,42 @@ export const HandleAi = ({ aiId }: Props) => {
</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>
);
}

View File

@@ -55,7 +55,8 @@ export const WelcomeSubscription = () => {
const [showConfetti, setShowConfetti] = useState(false);
const stepper = useStepper();
const [isOpen, setIsOpen] = useState(true);
const { push } = useRouter();
const router = useRouter();
const { push } = router;
useEffect(() => {
const confettiShown = localStorage.getItem("hasShownConfetti");
@@ -66,7 +67,22 @@ export const WelcomeSubscription = () => {
}, [showConfetti]);
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]">
{showConfetti ?? "Flaso"}
<div className="flex justify-center items-center w-full">

View File

@@ -116,6 +116,14 @@ export function TagSelector({
<HandleTag />
</div>
</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>
{tags.map((tag) => {
const isSelected = selectedTags.includes(tag.id);

View 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,
};

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "sendInvoiceNotifications" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1156,6 +1156,13 @@
"when": 1775369858244,
"tag": "0164_slippery_sasquatch",
"breakpoints": true
},
{
"idx": 165,
"version": "7",
"when": 1775845419261,
"tag": "0165_abnormal_greymalkin",
"breakpoints": true
}
]
}

View File

@@ -67,6 +67,7 @@
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@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-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",

View File

@@ -5,6 +5,10 @@ import { and, asc, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
import { organization, server, user } from "@/server/db/schema";
import {
sendInvoiceEmail,
sendPaymentFailedEmail,
} from "@/server/utils/stripe-notifications";
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
@@ -241,6 +245,11 @@ export default async function handler(
}
const newServersQuantity = admin.serversQuantity;
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
if (admin.sendInvoiceNotifications) {
await sendInvoiceEmail(newInvoice, admin);
}
break;
}
case "invoice.payment_failed": {
@@ -249,7 +258,6 @@ export default async function handler(
const subscription = await stripe.subscriptions.retrieve(
newInvoice.subscription as string,
);
if (subscription.status !== "active") {
const admin = await findUserByStripeCustomerId(
newInvoice.customer as string,
@@ -263,6 +271,10 @@ export default async function handler(
break;
}
if (admin.sendInvoiceNotifications) {
await sendPaymentFailedEmail(newInvoice, admin);
}
await db
.update(user)
.set({

View File

@@ -40,7 +40,7 @@ function DeploymentsPage() {
return (
<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">
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">

View File

@@ -12,6 +12,7 @@ import {
Loader2,
Play,
PlusIcon,
RefreshCw,
Search,
ServerIcon,
SquareTerminal,
@@ -68,6 +69,14 @@ import {
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
Dialog,
DialogContent,
@@ -424,6 +433,7 @@ const EnvironmentPage = (
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [deleteVolumes, setDeleteVolumes] = useState(false);
const [selectedServerId, setSelectedServerId] = useState<string>("all");
const [serviceToDelete, setServiceToDelete] = useState<Services | null>(null);
const handleSelectAll = () => {
if (selectedServices.length === filteredServices.length) {
@@ -814,6 +824,110 @@ const EnvironmentPage = (
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
const availableServers = useMemo(() => {
if (!applications) return [];
@@ -1472,110 +1586,156 @@ const EnvironmentPage = (
<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">
{filteredServices?.map((service) => (
<Link
key={service.id}
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)
}
<ContextMenu key={service.id}>
<ContextMenuTrigger asChild>
<Link
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
className="block"
>
<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>
<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>
)}
<DateTooltip date={service.createdAt}>
Created
</DateTooltip>
</div>
</CardFooter>
</Card>
</Link>
<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">
<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>
@@ -1586,6 +1746,38 @@ const EnvironmentPage = (
</div>
</Card>
</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>
);
};

View File

@@ -22,6 +22,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
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 { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
@@ -60,6 +61,7 @@ type TabState =
| "advanced"
| "deployments"
| "domains"
| "containers"
| "monitoring"
| "volumeBackups";
@@ -231,6 +233,9 @@ const Service = (
Deployments
</TabsTrigger>
)}
{permissions?.service.read && (
<TabsTrigger value="containers">Containers</TabsTrigger>
)}
{permissions?.service.create && (
<TabsTrigger value="backups">Backups</TabsTrigger>
)}
@@ -298,6 +303,18 @@ const Service = (
</div>
</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 && (
<TabsContent value="monitoring">
<div className="pt-2.5">

View File

@@ -82,6 +82,16 @@ export default function Home({ IS_CLOUD }: Props) {
});
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);
setError(error.message || "An error occurred while logging in");
return;

View File

@@ -25,9 +25,11 @@ import { findProjectById } from "@dokploy/server/services/project";
import {
getProviderHeaders,
getProviderName,
selectAIProvider,
type Model,
} from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateText } from "ai";
import { z } from "zod";
import { slugify } from "@/lib/slug";
import {
@@ -95,6 +97,30 @@ export const aiRouter = createTRPCRouter({
owned_by: "perplexity",
},
] 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:
if (!input.apiKey)
throw new TRPCError({
@@ -174,6 +200,107 @@ export const aiRouter = createTRPCRouter({
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
.input(
z.object({

View File

@@ -6,7 +6,9 @@ import {
findEnvironmentById,
findGitProviderById,
findProjectById,
getAccessibleServerIds,
getApplicationStats,
getContainerLogs,
IS_CLOUD,
mechanizeDockerContainer,
readConfig,
@@ -26,7 +28,6 @@ import {
updateDeploymentStatus,
writeConfig,
writeConfigRemote,
getAccessibleServerIds,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -1101,4 +1102,39 @@ export const applicationRouter = createTRPCRouter({
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,
);
}),
});

View File

@@ -16,7 +16,9 @@ import {
findGitProviderById,
findProjectById,
findServerById,
getAccessibleServerIds,
getComposeContainer,
getContainerLogs,
getWebServerSettings,
IS_CLOUD,
loadServices,
@@ -30,7 +32,6 @@ import {
stopCompose,
updateCompose,
updateDeploymentStatus,
getAccessibleServerIds,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -1130,4 +1131,44 @@ export const composeRouter = createTRPCRouter({
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,
);
}),
});

View File

@@ -1,6 +1,9 @@
import {
containerKill,
containerRemove,
containerRestart,
containerStart,
containerStop,
findServerById,
getConfig,
getContainers,
@@ -35,24 +38,108 @@ export const dockerRouter = createTRPCRouter({
return await getContainers(input.serverId);
}),
restartContainer: withPermission("docker", "read")
restartContainer: withPermission("service", "read")
.input(
z.object({
containerId: z
.string()
.min(1)
.regex(containerIdRegex, "Invalid container id."),
serverId: z.string().optional(),
}),
)
.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, {
action: "start",
resourceType: "docker",
resourceId: 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")

View File

@@ -6,6 +6,7 @@ import {
findEnvironmentById,
findLibsqlById,
findProjectById,
getContainerLogs,
IS_CLOUD,
rebuildDatabase,
removeLibsqlById,
@@ -466,4 +467,39 @@ export const libsqlRouter = createTRPCRouter({
});
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,
);
}),
});

View File

@@ -9,6 +9,8 @@ import {
findEnvironmentById,
findMariadbById,
findProjectById,
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -19,7 +21,6 @@ import {
stopService,
stopServiceRemote,
updateMariadbById,
getAccessibleServerIds,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -590,4 +591,39 @@ export const mariadbRouter = createTRPCRouter({
]);
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,
);
}),
});

View File

@@ -10,6 +10,7 @@ import {
findMongoById,
findProjectById,
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -601,4 +602,39 @@ export const mongoRouter = createTRPCRouter({
]);
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,
);
}),
});

View File

@@ -9,6 +9,7 @@ import {
findEnvironmentById,
findMySqlById,
findProjectById,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -604,4 +605,39 @@ export const mysqlRouter = createTRPCRouter({
]);
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,
);
}),
});

View File

@@ -9,6 +9,7 @@ import {
findEnvironmentById,
findPostgresById,
findProjectById,
getContainerLogs,
getMountPath,
getServiceContainerCommand,
IS_CLOUD,
@@ -614,4 +615,39 @@ export const postgresRouter = createTRPCRouter({
]);
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,
);
}),
});

View File

@@ -8,6 +8,7 @@ import {
findEnvironmentById,
findProjectById,
findRedisById,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -587,4 +588,39 @@ export const redisRouter = createTRPCRouter({
]);
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,
);
}),
});

View File

@@ -8,7 +8,11 @@ import {
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
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 {
apiCreateSshKey,
@@ -83,6 +87,16 @@ export const sshRouter = createTRPCRouter({
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")
.input(apiGenerateSSHKey)
.mutation(async ({ input }) => {

View File

@@ -205,11 +205,16 @@ export const stripeRouter = createTRPCRouter({
mode: "subscription",
line_items: items,
...(stripeCustomerId
? { customer: stripeCustomerId }
? {
customer: stripeCustomerId,
customer_update: { name: "auto", address: "auto" },
}
: { customer_email: owner.email }),
metadata: {
adminId: owner.id,
},
billing_address_collection: "required",
tax_id_collection: { enabled: true },
allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
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 }) => {
const user = await findUserById(ctx.user.ownerId);
const stripeCustomerId = user.stripeCustomerId;

View File

@@ -56,6 +56,8 @@ void app.prepare().then(async () => {
setupDockerStatsMonitoringSocketServer(server);
}
server.listen(PORT, HOST);
console.log(`Server Started on: http://${HOST}:${PORT}`);
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
createDefaultMiddlewares();
await initializeNetwork();
@@ -65,9 +67,6 @@ void app.prepare().then(async () => {
await initVolumeBackupsCronJobs();
await sendDokployRestartNotifications();
}
server.listen(PORT, HOST);
console.log(`Server Started on: http://${HOST}:${PORT}`);
await initEnterpriseBackupCronJobs();
if (!IS_CLOUD) {

View 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,
);
}
};

View File

@@ -33,7 +33,7 @@ app.use(async (c, next) => {
app.post("/create-backup", zValidator("json", jobQueueSchema), async (c) => {
const data = c.req.valid("json");
scheduleJob(data);
await scheduleJob(data);
logger.info({ data }, `[${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");
}
scheduleJob(data);
await scheduleJob(data);
logger.info("Backup updated successfully");
return c.json({ message: "Backup updated successfully" });
@@ -103,8 +103,11 @@ process.on("uncaughtException", (err) => {
logger.error(err, "Uncaught exception");
});
process.on("unhandledRejection", (reason, promise) => {
logger.error({ promise, reason }, "Unhandled Rejection at: Promise");
process.on("unhandledRejection", (reason, _promise) => {
logger.error(
reason instanceof Error ? reason : { reason: String(reason) },
"Unhandled Rejection at: Promise",
);
});
const port = Number.parseInt(process.env.PORT || "3000");

View File

@@ -21,28 +21,28 @@ export const cleanQueue = async () => {
}
};
export const scheduleJob = (job: QueueJob) => {
export const scheduleJob = async (job: QueueJob) => {
if (job.type === "backup") {
jobQueue.add(job.backupId, job, {
await jobQueue.add(job.backupId, job, {
repeat: {
pattern: job.cronSchedule,
},
});
} else if (job.type === "server") {
jobQueue.add(`${job.serverId}-cleanup`, job, {
await jobQueue.add(`${job.serverId}-cleanup`, job, {
repeat: {
pattern: job.cronSchedule,
},
});
} else if (job.type === "schedule") {
jobQueue.add(job.scheduleId, job, {
await jobQueue.add(job.scheduleId, job, {
repeat: {
pattern: job.cronSchedule,
tz: job.timezone || "UTC",
},
});
} else if (job.type === "volume-backup") {
jobQueue.add(job.volumeBackupId, job, {
await jobQueue.add(job.volumeBackupId, job, {
repeat: {
pattern: job.cronSchedule,
},

View File

@@ -135,11 +135,18 @@ export const initializeJobs = async () => {
for (const server of servers) {
const { serverId } = server;
scheduleJob({
serverId,
type: "server",
cronSchedule: CLEANUP_CRON_JOB,
});
try {
await scheduleJob({
serverId,
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");
@@ -157,11 +164,15 @@ export const initializeJobs = async () => {
});
for (const backup of backupsResult) {
scheduleJob({
backupId: backup.backupId,
type: "backup",
cronSchedule: backup.schedule,
});
try {
await scheduleJob({
backupId: backup.backupId,
type: "backup",
cronSchedule: backup.schedule,
});
} catch (error) {
logger.error(error, `Failed to schedule backup ${backup.backupId}`);
}
}
logger.info({ Quantity: backupsResult.length }, "Backups Initialized");
@@ -197,11 +208,15 @@ export const initializeJobs = async () => {
);
for (const schedule of filteredSchedulesBasedOnServerStatus) {
scheduleJob({
scheduleId: schedule.scheduleId,
type: "schedule",
cronSchedule: schedule.cronExpression,
});
try {
await scheduleJob({
scheduleId: schedule.scheduleId,
type: "schedule",
cronSchedule: schedule.cronExpression,
});
} catch (error) {
logger.error(error, `Failed to schedule ${schedule.scheduleId}`);
}
}
logger.info(
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
@@ -236,11 +251,18 @@ export const initializeJobs = async () => {
);
for (const volumeBackup of filteredVolumeBackupsBasedOnServerStatus) {
scheduleJob({
volumeBackupId: volumeBackup.volumeBackupId,
type: "volume-backup",
cronSchedule: volumeBackup.cronExpression,
});
try {
await scheduleJob({
volumeBackupId: volumeBackup.volumeBackupId,
type: "volume-backup",
cronSchedule: volumeBackup.cronExpression,
});
} catch (error) {
logger.error(
error,
`Failed to schedule volume backup ${volumeBackup.volumeBackupId}`,
);
}
}
logger.info(

View File

@@ -65,6 +65,9 @@ export const user = pgTable("user", {
stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0),
sendInvoiceNotifications: boolean("sendInvoiceNotifications")
.notNull()
.default(false),
isEnterpriseCloud: boolean("isEnterpriseCloud").notNull().default(false),
trustedOrigins: text("trustedOrigins").array(),
bookmarkedTemplates: text("bookmarkedTemplates")

View 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;

View 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;

View 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;

View File

@@ -21,7 +21,10 @@ import {
updateWebServerSettings,
} from "../services/web-server-settings";
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 { ac, adminRole, memberRole, ownerRole } from "./access-control";
@@ -106,14 +109,13 @@ const { handler, api } = betterAuth({
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendOnSignIn: true,
sendVerificationEmail: async ({ user, url }) => {
if (IS_CLOUD) {
await sendEmail({
await sendVerificationEmail({
userName: user.name || "User",
email: user.email,
subject: "Verify your email",
text: `
<p>Click the link to verify your email: <a href="${url}">Verify Email</a></p>
`,
verificationUrl: url,
});
}
},
@@ -196,7 +198,7 @@ const { handler, api } = betterAuth({
where: eq(schema.member.role, "owner"),
});
if (!IS_CLOUD) {
if (!IS_CLOUD && !isAdminPresent) {
await updateWebServerSettings({
serverIp: await getPublicIpWithFallback(),
});

View File

@@ -108,22 +108,45 @@ export const suggestVariants = async ({
ip = "127.0.0.1";
}
const suggestionsSchema = z.object({
const fullSchema = z.object({
suggestions: z.array(
z.object({
id: z.string(),
name: z.string(),
shortDescription: 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,
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: suggestionsSchema }),
output: Output.object({ schema: fullSchema }),
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:
@@ -139,163 +162,94 @@ export const suggestVariants = async ({
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
- 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": [
{
"id": "project-or-variant-slug",
"name": "Project Name or Variant Name",
"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)
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
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
2. The description field should ONLY contain plain text — no code snippets or installation instructions
3. The shortDescription should be a single-line summary focusing on key technologies or differentiators
4. All suggestions should be installable in docker and have docker compose support
5. 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 dockerSchema = z.object({
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 output = result.output as
| { suggestions: (SuggestionItem & DockerOutput)[] }
| undefined;
if (!output?.suggestions?.length) {
throw new TRPCError({
code: "NOT_FOUND",
message: "No suggestions found",
});
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({
code: "NOT_FOUND",
message: "No suggestions found",
});
return output.suggestions.filter((s) => s.dockerCompose);
} catch (error) {
console.error("Error in suggestVariants:", error);
throw error;

View File

@@ -440,17 +440,16 @@ export const removeCompose = async (
}
} else {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;
cd ${projectPath} && env -i PATH="$PATH" docker compose -p ${compose.appName} down ${
docker network disconnect ${compose.appName} dokploy-traefik;
env -i PATH="$PATH" docker compose -p ${compose.appName} down ${
deleteVolumes ? "--volumes" : ""
} && rm -rf ${projectPath}`;
};
rm -rf ${projectPath}`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, command);
} else {
await execAsync(command, {
cwd: projectPath,
});
await execAsync(command);
}
}
} catch (error) {

View File

@@ -354,21 +354,121 @@ export const getContainersByAppLabel = async (
return [];
};
export const containerRestart = async (containerId: string) => {
try {
const { stdout, stderr } = await execAsync(
`docker container restart ${containerId}`,
export const getContainerLogs = async (
appNameOrId: string,
tail = 100,
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) {
console.error(`Error: ${stderr}`);
return;
if (!containerId) {
// Fallback: try as a swarm service
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;
} catch {}
const escapedSearch = search?.replace(/'/g, "'\\''") ?? "";
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 (

View File

@@ -17,6 +17,9 @@ export function getProviderName(apiUrl: string) {
if (apiUrl.includes(":11434") || apiUrl.includes("ollama")) return "ollama";
if (apiUrl.includes("api.deepinfra.com")) return "deepinfra";
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";
}
@@ -87,6 +90,30 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
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":
return createOpenAICompatible({
name: "custom",

View File

@@ -106,6 +106,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
const envFilePath = join(dirname(composeFilePath), ".env");
let envContent = `APP_NAME=${appName}\n`;
envContent += `COMPOSE_PROJECT_NAME=${appName}\n`;
envContent += env || "";
if (!envContent.includes("DOCKER_CONFIG")) {
envContent += "\nDOCKER_CONFIG=/root/.docker";

View File

@@ -19,6 +19,7 @@ export const sendEmailNotification = async (
connection: typeof email.$inferInsert,
subject: string,
htmlContent: string,
attachments?: { filename: string; content: Buffer }[],
) => {
try {
const {
@@ -41,6 +42,7 @@ export const sendEmailNotification = async (
subject,
html: htmlContent,
textEncoding: "base64",
attachments,
});
} catch (err) {
console.log(err);

View File

@@ -151,16 +151,18 @@ export const createRouterConfig = async (
routerConfig.middlewares?.push("redirect-to-https");
} else {
// Add path rewriting middleware if needed
if (internalPath && internalPath !== "/" && internalPath !== path) {
const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(pathMiddleware);
}
// stripPrefix must come before addPrefix so Traefik strips the
// public path first, then prepends the internal path.
if (stripPath && path && path !== "/") {
const stripMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
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
// should not inherit parent redirect rules (e.g., www-redirect)
if (domain.domainType !== "preview") {

View File

@@ -1,12 +1,17 @@
import { renderAsync } from "@react-email/components";
import VerifyEmailTemplate from "../emails/emails/verify-email";
import { sendEmailNotification } from "../utils/notifications/utils";
export const sendEmail = async ({
email,
subject,
text,
attachments,
}: {
email: string;
subject: string;
text: string;
attachments?: { filename: string; content: Buffer }[];
}) => {
await sendEmailNotification(
{
@@ -19,7 +24,30 @@ export const sendEmail = async ({
},
subject,
text,
attachments,
);
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
View File

@@ -176,6 +176,9 @@ importers:
'@radix-ui/react-collapsible':
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)
'@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':
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)
@@ -2801,6 +2804,19 @@ packages:
'@types/react':
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':
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
peerDependencies:
@@ -10633,6 +10649,20 @@ snapshots:
optionalDependencies:
'@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)':
dependencies:
'@babel/runtime': 7.28.6