Compare commits

..

48 Commits

Author SHA1 Message Date
Mauricio Siu
d54aa02ad2 feat: enhance API parameter documentation with semantic hints
- Introduced semantic hints for API parameters in the endpoint catalog, providing clearer guidance on expected values and formats.
- Updated the endpoint text generation to include these hints, improving the usability of the API documentation for developers.
- Enhanced the overall clarity of parameter descriptions, facilitating better understanding and implementation of API calls.

These changes significantly improve the developer experience by making API interactions more intuitive and informative.
2026-04-12 11:55:41 -06:00
Mauricio Siu
734641b516 feat: implement embeddings for AI chat and enhance tool retrieval
- Introduced a new embeddings system for AI chat, allowing for improved context understanding and response accuracy.
- Added functionality to retrieve relevant endpoints based on user queries, enhancing the AI's ability to provide precise information.
- Updated the chat panel to restore messages from local storage and persist chat history, improving user experience.
- Enhanced error handling and added semantic hints for API parameters, ensuring clearer guidance for users.

These changes significantly improve the AI chat capabilities and overall interaction quality within the Dokploy platform.
2026-04-12 11:54:01 -06:00
Mauricio Siu
cee2e9f002 feat: enhance AI chat functionality and API integration
- Updated the AI chat panel to support multiple service types, including applications, databases, and more, improving context handling.
- Implemented local storage for chat messages, allowing users to retain their chat history.
- Enhanced API integration by adding new endpoints for reading deployment build logs and creating tools from OpenAPI specifications.
- Improved error handling and user experience in the chat interface, ensuring smoother interactions.

These changes significantly enhance the AI chat capabilities and overall user experience within the Dokploy platform.
2026-04-11 22:15:58 -06:00
Mauricio Siu
e0b4a13340 chore: remove unused import from settings router
- Removed the unused import of `tryCatch` from the settings router file, streamlining the code and improving readability.

This change contributes to cleaner code management by eliminating unnecessary dependencies.
2026-04-11 14:50:58 -06:00
Mauricio Siu
ec202c8c6e chore: update @dokploy/trpc-openapi to version 0.0.19 and enhance API documentation
- Updated the version of @dokploy/trpc-openapi from 0.0.18 to 0.0.19 in pnpm-lock.yaml and package.json.
- Added OpenAPI metadata to various procedures across multiple routers, improving API documentation and clarity for developers.
- Enhanced descriptions and summaries for several API endpoints, ensuring better understanding of their functionality.

These changes improve the overall API usability and documentation quality.
2026-04-11 14:50:28 -06:00
Mauricio Siu
7e89eaed4a Merge branch 'canary' into feat/add-chat 2026-04-11 10:14:13 -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
e508f3143f feat: implement AI chat panel and logging features
- Added a new ChatPanel component for AI interactions, allowing users to chat with an AI assistant for infrastructure management.
- Integrated the ChatPanel into the dashboard layout for easy access.
- Created a new API endpoint for AI chat functionality, enabling dynamic interactions based on user context.
- Implemented log reading capabilities across various services (application, compose, libsql, mariadb, mongo, mysql, postgres, redis) to enhance troubleshooting.
- Introduced utility functions for fetching container logs, improving the overall user experience in managing deployments.

This feature enriches the user interface by providing real-time AI assistance and log analysis, streamlining operational workflows.
2026-04-09 11:19:42 -06: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
autofix-ci[bot]
ebbc008dbe [autofix.ci] apply automated fixes 2026-04-05 23:17:33 +00:00
Mauricio Siu
645a81b2ce feat: add tooltip to Daily Docker Cleanup toggle
Add an informative tooltip explaining the cleanup behavior and linking
to Schedule Jobs docs for custom cleanup strategies.

Closes #3973
2026-04-05 17:16:51 -06:00
Mauricio Siu
a6db83c758 Merge pull request #4165 from Dokploy/fix/ntfy-test-error-message
fix: surface actual error message in ntfy test connection
2026-04-05 14:11:39 -06:00
Mauricio Siu
ac65cc97f4 fix: surface actual error message in ntfy test connection
The catch block was swallowing the real error from the ntfy server,
making it impossible to diagnose connection failures (e.g. SSL, DNS,
auth issues). Now the underlying error message is included in the
tRPC error response.

Closes #4047
2026-04-05 14:08:55 -06:00
Mauricio Siu
30d5493281 Merge pull request #4164 from Dokploy/fix/permission-checks-env-and-load-services
fix: correct permission checks for compose loadServices and env editing
2026-04-05 13:59:11 -06:00
Mauricio Siu
91b44720ef fix: correct permission checks for compose loadServices and env editing
- Change compose.loadServices permission from service:create to service:read
  since loading services from a compose file is a read-only operation
- Add saveEnvironment endpoint to compose router with envVars:write permission
- Update show-environment.tsx to use saveEnvironment mutations instead of
  generic update mutations for all service types (compose, databases)

Closes #4052
2026-04-05 13:52:53 -06:00
Mauricio Siu
f700017ccf Merge pull request #4163 from Dokploy/fix/slack-notification-mrkdwn
fix: replace deprecated Slack actions with mrkdwn link field
2026-04-05 13:46:00 -06:00
Mauricio Siu
9287721dbf Merge pull request #4054 from vincent-tarrit/4053-fix-slack-notifications-content
fix: actions in slack notification
2026-04-05 13:45:33 -06:00
Mauricio Siu
6cde04ea39 fix: replace deprecated Slack actions with mrkdwn link field
The actions array in Slack attachments requires Interactive Components
to be configured on the Slack app, which causes notifications to fail.
Replaces with a Details field using mrkdwn hyperlink syntax and adds
mrkdwn_in to ensure the link renders as clickable.

Closes #4053
2026-04-05 13:44:30 -06:00
Mauricio Siu
283eeeb3e6 Merge pull request #4161 from Dokploy/fix/compose-patch-ordering
fix: compose patches overwritten by domain injection
2026-04-05 13:35:40 -06:00
Mauricio Siu
19ae575fa8 fix: patches not applied to compose services
writeDomainsToCompose reads the compose file in Node.js before the
shell script runs, so patches applied as shell commands were being
overwritten by the stale pre-patch content.

Split patch execution into a separate step that runs before
getBuildComposeCommand, so the file is already patched when Node.js
reads it for domain injection.

Also added missing patch support to rebuildCompose which was skipping
patches entirely on redeploys.

Closes #4113
2026-04-05 13:28:18 -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
vincent-tarrit
c42a16d658 Merge branch 'Dokploy:canary' into 4053-fix-slack-notifications-content 2026-03-24 07:10:24 +01:00
vincent-tarrit
b222409129 lint: fix linter 2026-03-24 07:09:35 +01:00
vincent-tarrit
a322ac374c fix: actions in slack notification 2026-03-23 18:44:14 +01:00
95 changed files with 47761 additions and 23262 deletions

File diff suppressed because one or more lines are too long

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

@@ -0,0 +1,629 @@
"use client";
import { useChat } from "@ai-sdk/react";
import type { ChatContext } from "@dokploy/server/utils/ai/chat-tools";
import { DefaultChatTransport } from "ai";
import {
Bot,
Check,
ChevronDown,
Loader2,
Send,
Trash2,
Wrench,
X,
} from "lucide-react";
import { useRouter } from "next/router";
import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
function useChatContext(): ChatContext {
const router = useRouter();
const { query, pathname } = router;
return useMemo(() => {
const projectId =
typeof query.projectId === "string" ? query.projectId : undefined;
const environmentId =
typeof query.environmentId === "string"
? query.environmentId
: undefined;
const serverId =
typeof query.serverId === "string" ? query.serverId : undefined;
const serviceParams = [
{ key: "applicationId", type: "application" },
{ key: "composeId", type: "compose" },
{ key: "postgresId", type: "postgres" },
{ key: "mysqlId", type: "mysql" },
{ key: "redisId", type: "redis" },
{ key: "mongoId", type: "mongo" },
{ key: "mariadbId", type: "mariadb" },
{ key: "libsqlId", type: "libsql" },
] as const;
for (const { key, type } of serviceParams) {
if (query[key] && typeof query[key] === "string") {
return {
type,
id: query[key] as string,
projectId,
environmentId,
serverId,
};
}
}
if (query.projectId && typeof query.projectId === "string") {
return {
type: "project" as const,
id: query.projectId,
projectId,
environmentId,
serverId,
};
}
return {
type: "general" as const,
id: "",
projectId,
environmentId,
serverId,
};
}, [
query.applicationId,
query.composeId,
query.postgresId,
query.mysqlId,
query.redisId,
query.mongoId,
query.mariadbId,
query.libsqlId,
query.projectId,
query.environmentId,
query.serverId,
pathname,
]);
}
export function ChatPanel() {
const [open, setOpen] = useState(false);
const [aiId, setAiId] = useState<string>("");
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const context = useChatContext();
const aiIdRef = useRef(aiId);
const contextRef = useRef(context);
aiIdRef.current = aiId;
contextRef.current = context;
const { data: isCloud } = api.settings.isCloud.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
refetchOnWindowFocus: false,
enabled: !isCloud,
});
const enabledProviders = providers ?? [];
const STORAGE_KEY = "dokploy-chat-messages";
const restoredRef = useRef(false);
const { messages, sendMessage, status, setMessages, addToolApprovalResponse } = useChat({
id: "dokploy-chat",
transport: new DefaultChatTransport({
api: "/api/ai/chat",
body: () => ({
...(isCloud ? {} : { aiId: aiIdRef.current }),
context: contextRef.current,
}),
}),
});
const isLoading = status === "streaming" || status === "submitted";
// Restore messages from localStorage on mount
useEffect(() => {
if (restoredRef.current) return;
restoredRef.current = true;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed);
}
}
} catch {
// ignore
}
}, [setMessages]);
// Persist messages to localStorage
useEffect(() => {
if (!restoredRef.current) return;
if (messages.length > 0) {
try {
const toStore = messages.slice(-50);
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
} catch {
// localStorage full or unavailable — ignore
}
}
}, [messages]);
useEffect(() => {
if (!isCloud && !aiId && enabledProviders.length > 0 && enabledProviders[0]) {
setAiId(enabledProviders[0].aiId);
}
}, [enabledProviders, aiId, isCloud]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, status]);
if (!isCloud && enabledProviders.length === 0) return null;
const handleSend = () => {
if (!input.trim() || isLoading) return;
if (!isCloud && !aiId) return;
sendMessage({ text: input });
setInput("");
setTimeout(() => inputRef.current?.focus(), 0);
};
const contextLabel =
context.type === "general" ? "General" : context.type;
const lastMessage = messages[messages.length - 1];
return (
<>
<Button
onClick={() => setOpen(true)}
variant="outline"
className="fixed bottom-6 right-6 z-50 h-11 w-11 rounded-full shadow-md border"
size="icon"
>
<Bot className="h-5 w-5" />
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-full sm:w-[480px] p-0 flex flex-col border-l outline-none"
>
<SheetHeader className="px-4 py-3 border-b shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 text-muted-foreground" />
<SheetTitle className="text-sm font-medium">
{isCloud ? "Dokploy Agent" : "AI Assistant"}
</SheetTitle>
{isLoading && (
<span className="text-xs text-muted-foreground animate-pulse">
working...
</span>
)}
</div>
</div>
<SheetDescription className="sr-only">
Chat with AI to manage your infrastructure
</SheetDescription>
<div className="flex items-center gap-2 pt-1">
{!isCloud && (
<Select value={aiId} onValueChange={setAiId}>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="Select provider..." />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.aiId} value={p.aiId}>
{p.name} ({p.model})
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Badge
variant="outline"
className="text-xs shrink-0 capitalize font-normal"
>
{contextLabel}
</Badge>
{messages.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => { setMessages([]); localStorage.removeItem(STORAGE_KEY); }}
title="Clear chat"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</SheetHeader>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 space-y-3"
>
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
<Bot className="h-8 w-8 opacity-30" />
<p className="text-sm text-center">
Ask me anything about your{" "}
{context.type === "general"
? "infrastructure"
: context.type}
</p>
<div className="flex flex-wrap gap-1.5 justify-center">
{(context.type === "application"
? [
"What's the status of this app?",
"Why did the last build fail?",
"Show me recent deployments",
"Redeploy this app",
]
: context.type === "compose"
? [
"Show compose service status",
"Why did the last deploy fail?",
"Show me the domains",
"Redeploy this service",
]
: context.type === "postgres" ||
context.type === "mysql" ||
context.type === "redis" ||
context.type === "mongo" ||
context.type === "mariadb" ||
context.type === "libsql"
? [
`Show ${context.type} status`,
"What's the connection info?",
"Show recent deployments",
"Restart this database",
]
: context.type === "project"
? [
"How many services do I have?",
"Show me all environments",
"Which services are failing?",
]
: [
"List all my projects",
"Show project overview",
"What servers do I have?",
]
).map((suggestion) => (
<Button
key={suggestion}
variant="outline"
size="sm"
className="text-xs h-7 font-normal"
onClick={() => setInput(suggestion)}
>
{suggestion}
</Button>
))}
</div>
</div>
)}
{messages.map((message) => {
if (message.role === "user") {
return (
<div key={message.id} className="flex justify-end">
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm bg-muted">
<p className="whitespace-pre-wrap">
{message.parts
.filter(
(p): p is { type: "text"; text: string } =>
p.type === "text",
)
.map((p) => p.text)
.join("")}
</p>
</div>
</div>
);
}
return (
<div key={message.id} className="flex justify-start">
<div className="max-w-[90%] space-y-2">
{message.parts.map((part, i) => {
if (
part.type === "text" &&
(part as { text?: string }).text?.trim()
) {
return (
<div
key={`text-${message.id}-${i}`}
className="rounded-lg border px-3 py-2 text-sm prose prose-sm dark:prose-invert max-w-none break-words"
>
<ReactMarkdown>
{(part as { text: string }).text}
</ReactMarkdown>
</div>
);
}
if (part.type === "dynamic-tool") {
return (
<div
key={part.toolCallId}
className="rounded-lg border px-3 py-2"
>
<ToolCallDisplay
toolCallId={part.toolCallId}
toolName={part.toolName}
state={part.state}
input={(part as any).input}
output={
part.state === "output-available"
? part.output
: undefined
}
onApprove={(id) =>
addToolApprovalResponse({
id,
approved: true,
})
}
onDeny={(id) =>
addToolApprovalResponse({
id,
approved: false,
reason: "User denied",
})
}
/>
</div>
);
}
if (part.type === "reasoning") {
return (
<Collapsible key={`reasoning-${message.id}-${i}`}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bot className="h-3 w-3" />
<span>Thinking...</span>
<ChevronDown className="h-3 w-3" />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 rounded-lg border px-3 py-2 text-xs text-muted-foreground italic">
{(part as any).text ||
(part as any).reasoning}
</div>
</CollapsibleContent>
</Collapsible>
);
}
return null;
})}
</div>
</div>
);
})}
{isLoading && lastMessage?.role === "user" && (
<div className="flex justify-start">
<div className="rounded-lg border px-3 py-2 flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Investigating...
</div>
</div>
)}
</div>
<div className="border-t p-3 shrink-0 flex gap-2">
<Textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={
!isCloud && !aiId
? "Select a provider first..."
: "Ask anything..."
}
disabled={(!isCloud && !aiId) || isLoading}
className="min-h-[40px] max-h-[120px] resize-none text-sm"
rows={1}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<Button
type="button"
size="icon"
variant="outline"
disabled={
(!isCloud && !aiId) || !input.trim() || isLoading
}
className="shrink-0 h-10 w-10"
onClick={handleSend}
>
<Send className="h-4 w-4" />
</Button>
</div>
</SheetContent>
</Sheet>
</>
);
}
function ToolCallDisplay({
toolCallId,
toolName,
state,
input,
output,
onApprove,
onDeny,
}: {
toolCallId: string;
toolName: string;
state: string;
input?: unknown;
output?: unknown;
onApprove?: (id: string) => void;
onDeny?: (id: string) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const isRunning =
state === "input-streaming" || state === "input-available";
const isDone = state === "output-available";
const isError = state === "output-error";
const needsApproval = state === "requires-approval";
const outputText = output
? typeof output === "string"
? output
: JSON.stringify(output, null, 2)
: null;
// Extract operationId and params from input
const inputData = input as { operationId?: string; params?: Record<string, unknown> } | undefined;
const operationId = inputData?.operationId;
const params = inputData?.params;
// Format: "compose-one" → "compose → one"
const displayLabel = operationId
? operationId.replace("-", " → ")
: toolName;
// Determine HTTP method hint from operationId
const isReadOp = operationId?.match(/^(.*-)?(one|all|get|list|read|search|by)/i);
const StatusIcon = isRunning
? () => <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500 shrink-0" />
: isDone
? () => <Check className="h-3.5 w-3.5 text-green-500 shrink-0" />
: isError
? () => <X className="h-3.5 w-3.5 text-red-500 shrink-0" />
: () => <Wrench className="h-3.5 w-3.5 text-muted-foreground shrink-0" />;
if (needsApproval) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs">
<Wrench className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
<code className="font-mono text-xs font-medium">{displayLabel}</code>
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4 font-normal">
write
</Badge>
</div>
{params && Object.keys(params).length > 0 && (
<div className="ml-5.5 flex flex-wrap gap-1">
{Object.entries(params).map(([key, value]) => (
<span key={key} className="text-[10px] bg-muted px-1.5 py-0.5 rounded font-mono">
{key}={typeof value === "string" ? `"${value}"` : String(value)}
</span>
))}
</div>
)}
<div className="flex gap-1.5 ml-5.5">
<Button
variant="outline"
size="sm"
className="h-6 text-xs px-2"
onClick={() => onApprove?.(toolCallId)}
>
<Check className="h-3 w-3 mr-1" />
Approve
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs px-2"
onClick={() => onDeny?.(toolCallId)}
>
<X className="h-3 w-3 mr-1" />
Deny
</Button>
</div>
</div>
);
}
return (
<div className="space-y-1">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-2 text-xs w-full hover:bg-muted/50 rounded -mx-1 px-1 py-0.5 transition-colors"
>
<StatusIcon />
<code className="font-mono text-xs font-medium">{displayLabel}</code>
{isReadOp && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 font-normal">
read
</Badge>
)}
{params && Object.keys(params).length > 0 && (
<span className="text-[10px] text-muted-foreground truncate">
{Object.entries(params)
.slice(0, 3)
.map(([k, v]) => `${k}=${typeof v === "string" ? `"${String(v).slice(0, 20)}"` : String(v)}`)
.join(", ")}
{Object.keys(params).length > 3 ? ` +${Object.keys(params).length - 3}` : ""}
</span>
)}
{(outputText || isRunning) && (
<ChevronDown
className={`h-3 w-3 ml-auto text-muted-foreground transition-transform shrink-0 ${isOpen ? "rotate-180" : ""}`}
/>
)}
</button>
</CollapsibleTrigger>
{outputText && (
<CollapsibleContent>
<pre className="mt-1 ml-5.5 p-2 bg-muted/50 rounded text-[10px] overflow-x-auto max-h-[200px] overflow-y-auto leading-tight whitespace-pre-wrap break-words">
{outputText}
</pre>
</CollapsibleContent>
)}
</Collapsible>
</div>
);
}

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

@@ -56,17 +56,17 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const mutationMap = {
compose: () => api.compose.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
compose: () => api.compose.saveEnvironment.useMutation(),
libsql: () => api.libsql.saveEnvironment.useMutation(),
mariadb: () => api.mariadb.saveEnvironment.useMutation(),
mongo: () => api.mongo.saveEnvironment.useMutation(),
mysql: () => api.mysql.saveEnvironment.useMutation(),
postgres: () => api.postgres.saveEnvironment.useMutation(),
redis: () => api.redis.saveEnvironment.useMutation(),
};
const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
: api.mongo.saveEnvironment.useMutation();
const form = useForm<EnvironmentSchema>({
defaultValues: {

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

@@ -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

@@ -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

@@ -1,7 +1,14 @@
import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { HelpCircle } from "lucide-react";
import { toast } from "sonner";
interface Props {
serverId?: string;
@@ -52,7 +59,36 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
return (
<div className="flex items-center gap-4">
<Switch checked={!!enabled} onCheckedChange={handleToggle} />
<Label className="text-primary">Daily Docker Cleanup</Label>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
Daily Docker Cleanup
<HelpCircle className="size-4 text-muted-foreground" />
</Label>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm">
<p>
Runs a full Docker cleanup daily, pruning stopped containers,
unused images, volumes, build cache, and system resources. This
may remove images built for Compose services that run on-demand
(backup runners, cron jobs, one-off tasks).
</p>
<p className="mt-1">
For custom cleanup strategies, use{" "}
<a
href="https://docs.dokploy.com/docs/core/schedule-jobs#example-1-automatic-docker-cleanup"
target="_blank"
rel="noopener noreferrer"
className="underline text-primary"
>
Schedule Jobs
</a>{" "}
on your web server or remote servers.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};

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

@@ -1,4 +1,5 @@
import { api } from "@/utils/api";
import { ChatPanel } from "../dashboard/ai-chat/chat-panel";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { HubSpotWidget } from "../shared/HubSpotWidget";
import Page from "./side";
@@ -23,6 +24,7 @@ export const DashboardLayout = ({ children }: Props) => {
return (
<>
<Page>{children}</Page>
<ChatPanel />
{isChatEnabled && (
<>
<HubSpotWidget />

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

@@ -46,6 +46,7 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@ai-sdk/react": "^3.0.156",
"@better-auth/api-key": "1.5.4",
"@better-auth/sso": "1.5.4",
"@codemirror/autocomplete": "^6.18.6",
@@ -57,7 +58,7 @@
"@codemirror/search": "^6.6.0",
"@codemirror/view": "^6.39.15",
"@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.18",
"@dokploy/trpc-openapi": "0.0.19",
"@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^5.2.2",
"@octokit/auth-app": "^6.1.3",

View File

@@ -0,0 +1,251 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { getAiSettingById } from "@dokploy/server/services/ai";
import {
type ChatContext,
getAllTools,
} from "@dokploy/server/utils/ai/chat-tools";
import {
buildEndpointCatalog,
createApiTool,
} from "@dokploy/server/utils/ai/api-tool";
import {
getOrCreateEmbeddings,
retrieveRelevantEndpoints,
} from "@dokploy/server/utils/ai/tool-retrieval";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { createAnthropic } from "@ai-sdk/anthropic";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import type { NextApiRequest, NextApiResponse } from "next";
let cachedSpec: any = null;
function getOpenApiSpec() {
if (!cachedSpec) {
try {
const specPath = join(process.cwd(), "../../openapi.json");
cachedSpec = JSON.parse(readFileSync(specPath, "utf-8"));
} catch {
cachedSpec = null;
}
}
return cachedSpec;
}
function buildContextBlock(context: ChatContext): string {
if (context.type === "general") {
return "CONTEXT: The user is on the general dashboard (no specific resource selected). Use project-all to list their projects if needed.";
}
const lines: string[] = [];
lines.push(
`CONTEXT: The user is currently viewing a specific ${context.type}. The ${context.type}Id is "${context.id}".`,
);
lines.push(
`When the user says "this app", "this service", "this database", "add env var", etc., they ALWAYS mean this ${context.type} (ID: "${context.id}"). NEVER ask which service they mean.`,
);
if (context.projectId) {
lines.push(`- projectId: "${context.projectId}"`);
}
if (context.environmentId) {
lines.push(`- environmentId: "${context.environmentId}"`);
}
if (context.serverId) {
lines.push(`- serverId: "${context.serverId}"`);
}
lines.push(
"Use these IDs directly when calling tools — do NOT ask the user for them. You already know exactly which resource the user is talking about.",
);
return lines.join("\n");
}
function buildSystemPrompt(context: ChatContext, catalog: string | null, endpointCount?: number) {
const contextBlock = buildContextBlock(context);
return `You are an autonomous DevOps agent inside Dokploy (Docker-based PaaS). You take action immediately — you don't explain, you don't ask, you DO.
${contextBlock}
THINKING PROCESS (do this before EVERY action):
1. Scan ALL section headers (## tag — description) in the ENDPOINT CATALOG to find which sections are relevant
2. Read the endpoint descriptions in those sections to pick the right operationId
3. Call the endpoint with the correct params — use the IDs from the context above
BEHAVIOR:
- When the user asks you to do something → DO IT. Call the API right away.
- When you need information → call the endpoint to get it. Never say "I can't access" or "I don't have the ability to".
- When something fails → read the error, figure out the fix, and apply it. Don't stop to explain the error — fix it.
- EVERY capability you need is in the ENDPOINT CATALOG below. If you think you can't do something, you're wrong — scan ALL sections again.
- You already have all the IDs you need from the context above. NEVER ask the user for IDs, paths, or information you can discover by calling endpoints.
- NEVER ask for confirmation or permission. The only exception is deleting a service entirely. For everything else (read, update, deploy, stop, start, restart) → just do it immediately.
KEY PATTERN: When you need to explore files, find paths, or check repository structure → use the "patch" section endpoints to browse directories and read files. NEVER ask the user for file paths.
DATA MODEL: Project → Environment → Services (application, compose, postgres, mysql, redis, mongo, mariadb, libsql). Each service has deployments with build logs.
TOOL: You have one tool "call_api". Pass operationId + params from the catalog.
- ALWAYS pass required params (*) in the "params" object. Example: { "operationId": "domain-byComposeId", "params": { "composeId": "abc123" } }
- Params: * = required, ? = optional, [a|b|c] = allowed values
- GET = read-only (auto-executed). POST/PUT/DELETE = write (user approves).
- If a call fails, read the error message and fix the params. NEVER retry the same call with the same params.
RESPONSE STYLE:
- 2-3 sentences max. No walls of text.
- Never explain limitations — find the right endpoint and act.
- Answer in the user's language.
${catalog ? `ENDPOINT CATALOG (${endpointCount} endpoints):\n${catalog}` : ""}`;
}
function getUserMessages(messages: any[]): string {
const texts: string[] = [];
for (const msg of messages) {
if (msg.role !== "user") continue;
if (typeof msg.content === "string") {
texts.push(msg.content);
} else if (Array.isArray(msg.content)) {
texts.push(
msg.content
.filter((p: any) => p.type === "text")
.map((p: any) => p.text)
.join(" "),
);
}
}
return texts.slice(-3).join(". ");
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { session, user } = await validateRequest(req);
if (!user || !session) {
return res.status(401).json({ error: "Unauthorized" });
}
const body = req.body;
const messages = body.messages;
const aiId = body.aiId;
const context = (body.context as ChatContext) || {
type: "general" as const,
id: "",
};
// ─── Resolve model ────────────────────────────────────────
let model: any;
if (IS_CLOUD && process.env.CLOUD_ANTHROPIC_API_KEY) {
const anthropic = createAnthropic({
apiKey: process.env.CLOUD_ANTHROPIC_API_KEY,
});
model = anthropic("claude-haiku-4-5-20251001");
} else {
if (!aiId || !messages) {
return res
.status(400)
.json({ error: "Missing aiId or messages" });
}
const aiSettings = await getAiSettingById(aiId);
if (!aiSettings || !aiSettings.isEnabled) {
return res
.status(400)
.json({ error: "AI provider not enabled" });
}
const provider = selectAIProvider(aiSettings);
model = provider(aiSettings.model);
}
if (!messages) {
return res.status(400).json({ error: "Missing messages" });
}
// ─── Resolve tools ────────────────────────────────────────
const protocol = req.headers["x-forwarded-proto"] || "http";
const host = req.headers.host || "localhost:3000";
const toolConfig = {
baseUrl: `${protocol}://${host}/api`,
cookie: req.headers.cookie || "",
};
let tools: Record<string, any>;
let catalogText: string | null = null;
let endpointCount = 0;
const spec = getOpenApiSpec();
if (spec) {
const voyageApiKey = process.env.VOYAGE_API_KEY;
if (!voyageApiKey) {
return res.status(400).json({ error: "VOYAGE_API_KEY is required" });
}
const embeddingsPath = join(process.cwd(), ".tool-embeddings.json");
const allEmbeddings = await getOrCreateEmbeddings(
spec,
voyageApiKey,
embeddingsPath,
);
const userQuery = getUserMessages(messages).trim();
const { operationIds: tagFilteredIds } = buildEndpointCatalog(spec, context.type);
let relevantIds: Set<string> | undefined;
if (userQuery && allEmbeddings.length > 0) {
const topIds = await retrieveRelevantEndpoints(
userQuery,
allEmbeddings,
voyageApiKey,
{ allowedOperationIds: tagFilteredIds, topK: 25 },
);
if (topIds.length > 0) {
relevantIds = new Set(topIds);
}
}
const { catalog, count, operationIds } = buildEndpointCatalog(
spec,
context.type,
relevantIds,
);
catalogText = catalog;
endpointCount = count;
tools = createApiTool(spec, toolConfig, operationIds, 8000);
} else {
tools = getAllTools(context, toolConfig);
}
// ─── Stream response ──────────────────────────────────────
const modelMessages = await convertToModelMessages(messages);
const result = streamText({
model,
system: buildSystemPrompt(context, catalogText, endpointCount),
messages: modelMessages,
tools,
stopWhen: stepCountIs(12),
});
// Disable buffering for streaming
res.setHeader("X-Accel-Buffering", "no");
res.setHeader("Cache-Control", "no-cache, no-transform");
result.pipeUIMessageStreamToResponse(res);
} catch (error) {
console.error("AI chat error:", error);
return res.status(500).json({
error:
error instanceof Error ? error.message : "Internal server error",
});
}
}

View File

@@ -53,14 +53,9 @@ export default async function handler(
if (sourceType === "github") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits =
req.body?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
@@ -78,14 +73,9 @@ export default async function handler(
}
} else if (sourceType === "gitlab") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits =
req.body?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
@@ -134,32 +124,17 @@ export default async function handler(
let normalizedCommits: string[] = [];
if (provider === "github") {
normalizedCommits =
req.body?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
} else if (provider === "gitlab") {
normalizedCommits =
req.body?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
} else if (provider === "gitea") {
normalizedCommits =
req.body?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
}
const shouldDeployPaths = shouldDeploy(
@@ -174,14 +149,9 @@ export default async function handler(
} else if (sourceType === "gitea") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits =
req.body?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,

View File

@@ -213,14 +213,9 @@ export default async function handler(
const deploymentTitle = extractCommitMessage(req.headers, req.body);
const deploymentHash = extractHash(req.headers, req.body);
const owner = githubBody?.repository?.owner?.name;
const normalizedCommits =
githubBody?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
const normalizedCommits = githubBody?.commits?.flatMap(
(commit: any) => commit.modified,
);
const apps = await db.query.applications.findMany({
where: and(

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

@@ -10,6 +10,12 @@ import { adminProcedure, createTRPCRouter } from "../trpc";
export const adminRouter = createTRPCRouter({
setupMonitoring: adminProcedure
.meta({
openapi: {
summary: "Update web server monitoring settings",
description: "Update the monitoring configuration for the web server including refresh rates, thresholds, and container services. Restarts the monitoring system and returns the updated settings. Disabled on cloud.",
},
})
.input(apiUpdateWebServerMonitoring)
.mutation(async ({ input }) => {
try {

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 {
@@ -39,12 +41,24 @@ import { generatePassword } from "@/templates/utils";
export const aiRouter = createTRPCRouter({
one: adminProcedure
.meta({
openapi: {
summary: "Get AI settings by ID",
description: "Returns a single AI provider configuration by its ID.",
},
})
.input(z.object({ aiId: z.string() }))
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
}),
getModels: protectedProcedure
.meta({
openapi: {
summary: "List available AI models",
description: "Fetches the list of models from the given AI provider URL. Supports OpenAI-compatible, Ollama, Gemini, Perplexity, ZAI, and MiniMax providers.",
},
})
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
.query(async ({ input }) => {
try {
@@ -95,6 +109,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({
@@ -148,33 +186,194 @@ export const aiRouter = createTRPCRouter({
});
}
}),
create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
create: adminProcedure
.meta({
openapi: {
summary: "Create AI provider",
description: "Saves a new AI provider configuration (API URL, key, model) for the current organization.",
},
})
.input(apiCreateAi)
.mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => {
update: adminProcedure
.meta({
openapi: {
summary: "Update AI provider",
description: "Updates an existing AI provider configuration for the current organization.",
},
})
.input(apiUpdateAi)
.mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
getAll: adminProcedure.query(async ({ ctx }) => {
getAll: adminProcedure
.meta({
openapi: {
summary: "List all AI providers",
description: "Returns all AI provider configurations for the current organization.",
},
})
.query(async ({ ctx }) => {
return await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
}),
get: adminProcedure
.meta({
openapi: {
summary: "Get AI provider",
description: "Returns a single AI provider configuration by its ID.",
},
})
.input(z.object({ aiId: z.string() }))
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
}),
delete: adminProcedure
.meta({
openapi: {
summary: "Delete AI provider",
description: "Removes an AI provider configuration by its ID.",
},
})
.input(z.object({ aiId: z.string() }))
.mutation(async ({ input }) => {
return await deleteAiSettings(input.aiId);
}),
getEnabledProviders: protectedProcedure
.meta({
openapi: {
summary: "List enabled AI providers",
description: "Returns a lightweight list of enabled AI providers (ID, name, model) for the current organization, suitable for dropdown selectors.",
},
})
.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
.meta({
openapi: {
summary: "Analyze logs with AI",
description: "Sends build or runtime logs to the specified AI provider for analysis. Returns a summary of issues found, root causes, and suggested fixes.",
},
})
.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
.meta({
openapi: {
summary: "Test AI provider connection",
description: "Sends a minimal prompt to the specified AI provider and model to verify the API URL, key, and model are valid and reachable.",
},
})
.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
.meta({
openapi: {
summary: "Suggest deployment variants",
description: "Uses AI to generate deployment configuration suggestions (docker-compose variants) based on the user's input prompt.",
},
})
.input(
z.object({
aiId: z.string(),
@@ -196,6 +395,12 @@ export const aiRouter = createTRPCRouter({
}
}),
deploy: protectedProcedure
.meta({
openapi: {
summary: "Deploy AI suggestion",
description: "Deploys an AI-generated suggestion by creating a compose service with its docker-compose file, environment variables, domains, and config file mounts.",
},
})
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
const environment = await findEnvironmentById(input.environmentId);

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 {
@@ -78,6 +79,12 @@ import { cancelDeployment, deploy } from "@/server/utils/deploy";
export const applicationRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create an application",
description: "Creates a new application in the specified project environment. Supports GitHub, GitLab, Bitbucket, Git, Docker image, and drop sources.",
},
})
.input(apiCreateApplication)
.mutation(async ({ input, ctx }) => {
try {
@@ -133,6 +140,12 @@ export const applicationRouter = createTRPCRouter({
}
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get an application",
description: "Retrieves detailed information about an application by its ID, including git provider access status and deployment configuration.",
},
})
.input(apiFindOneApplication)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.applicationId, "read");
@@ -188,6 +201,12 @@ export const applicationRouter = createTRPCRouter({
}),
reload: protectedProcedure
.meta({
openapi: {
summary: "Reload an application",
description: "Restarts the Docker container for the application by mechanizing it. Resets the application status to idle, then to done on success or error on failure.",
},
})
.input(apiReloadApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -217,6 +236,12 @@ export const applicationRouter = createTRPCRouter({
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete an application",
description: "Permanently deletes an application and cleans up all associated resources including Docker services, Traefik configuration, deployments, middlewares, and source code.",
},
})
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.applicationId, "delete");
@@ -278,6 +303,12 @@ export const applicationRouter = createTRPCRouter({
}),
stop: protectedProcedure
.meta({
openapi: {
summary: "Stop an application",
description: "Stops the running Docker service for the application and sets its status to idle.",
},
})
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -300,6 +331,12 @@ export const applicationRouter = createTRPCRouter({
}),
start: protectedProcedure
.meta({
openapi: {
summary: "Start an application",
description: "Starts the Docker service for the application and sets its status to done.",
},
})
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -322,6 +359,12 @@ export const applicationRouter = createTRPCRouter({
}),
redeploy: protectedProcedure
.meta({
openapi: {
summary: "Redeploy an application",
description: "Triggers a rebuild and redeployment of the application. Queues a deployment job or executes it directly for cloud servers.",
},
})
.input(apiRedeployApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -366,6 +409,12 @@ export const applicationRouter = createTRPCRouter({
});
}),
saveEnvironment: protectedProcedure
.meta({
openapi: {
summary: "Save environment variables",
description: "Updates the environment variables, build arguments, and build secrets for an application.",
},
})
.input(apiSaveEnvironmentVariables)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -387,6 +436,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
saveBuildType: protectedProcedure
.meta({
openapi: {
summary: "Save build type configuration",
description: "Updates the build type and related settings for an application, including Dockerfile path, build context, publish directory, and build stage.",
},
})
.input(apiSaveBuildType)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -412,6 +467,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
saveGithubProvider: protectedProcedure
.meta({
openapi: {
summary: "Save GitHub provider",
description: "Configures the application to use a GitHub repository as its source, setting the repository, branch, owner, and build path.",
},
})
.input(apiSaveGithubProvider)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -439,6 +500,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
saveGitlabProvider: protectedProcedure
.meta({
openapi: {
summary: "Save GitLab provider",
description: "Configures the application to use a GitLab repository as its source, setting the repository, branch, owner, build path, and project ID.",
},
})
.input(apiSaveGitlabProvider)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -467,6 +534,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
saveBitbucketProvider: protectedProcedure
.meta({
openapi: {
summary: "Save Bitbucket provider",
description: "Configures the application to use a Bitbucket repository as its source, setting the repository, branch, owner, and build path.",
},
})
.input(apiSaveBitbucketProvider)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -494,6 +567,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
saveGiteaProvider: protectedProcedure
.meta({
openapi: {
summary: "Save Gitea provider",
description: "Configures the application to use a Gitea repository as its source, setting the repository, branch, owner, and build path.",
},
})
.input(apiSaveGiteaProvider)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -520,6 +599,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
saveDockerProvider: protectedProcedure
.meta({
openapi: {
summary: "Save Docker provider",
description: "Configures the application to use a Docker image as its source, setting the image name, registry URL, and optional credentials.",
},
})
.input(apiSaveDockerProvider)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -543,6 +628,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
saveGitProvider: protectedProcedure
.meta({
openapi: {
summary: "Save Git provider",
description: "Configures the application to use a custom Git repository URL as its source, with optional SSH key authentication.",
},
})
.input(apiSaveGitProvider)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -568,6 +659,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
disconnectGitProvider: protectedProcedure
.meta({
openapi: {
summary: "Disconnect git provider",
description: "Removes all git provider configuration from the application, resetting source type to default and clearing repository, branch, and owner fields for all providers.",
},
})
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -621,6 +718,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
markRunning: protectedProcedure
.meta({
openapi: {
summary: "Mark application as running",
description: "Sets the application status to running. Used to indicate that a deployment is in progress.",
},
})
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -636,6 +739,12 @@ export const applicationRouter = createTRPCRouter({
});
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update an application",
description: "Updates the general configuration of an application such as name, description, memory limits, CPU limits, and other settings.",
},
})
.input(apiUpdateApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -672,6 +781,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
refreshToken: protectedProcedure
.meta({
openapi: {
summary: "Refresh deploy token",
description: "Regenerates the webhook refresh token for the application, invalidating the previous token used for triggering deployments.",
},
})
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -690,6 +805,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
deploy: protectedProcedure
.meta({
openapi: {
summary: "Deploy an application",
description: "Triggers a new deployment for the application. Queues a deployment job or executes it directly for cloud servers.",
},
})
.input(apiDeployApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -734,6 +855,12 @@ export const applicationRouter = createTRPCRouter({
}),
cleanQueues: protectedProcedure
.meta({
openapi: {
summary: "Clean deployment queues",
description: "Removes all pending deployment jobs from the queue for the specified application.",
},
})
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -742,6 +869,12 @@ export const applicationRouter = createTRPCRouter({
await cleanQueuesByApplication(input.applicationId);
}),
clearDeployments: protectedProcedure
.meta({
openapi: {
summary: "Clear old deployments",
description: "Removes old deployment logs and artifacts for the application to free up disk space.",
},
})
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -758,6 +891,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
killBuild: protectedProcedure
.meta({
openapi: {
summary: "Kill active build",
description: "Forcefully terminates the currently running Docker build process for the application.",
},
})
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -773,6 +912,12 @@ export const applicationRouter = createTRPCRouter({
});
}),
readTraefikConfig: protectedProcedure
.meta({
openapi: {
summary: "Read Traefik configuration",
description: "Reads the current Traefik reverse proxy configuration file for the application. Supports both local and remote server configurations.",
},
})
.input(apiFindOneApplication)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -792,6 +937,12 @@ export const applicationRouter = createTRPCRouter({
}),
dropDeployment: protectedProcedure
.meta({
openapi: {
summary: "Deploy from zip upload",
description: "Deploys an application from an uploaded zip file. Unzips the file into the application directory and triggers a deployment.",
},
})
.input(
zfd.formData({
applicationId: z.string(),
@@ -848,6 +999,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
updateTraefikConfig: protectedProcedure
.meta({
openapi: {
summary: "Update Traefik configuration",
description: "Writes a new Traefik reverse proxy configuration for the application. Supports both local and remote server configurations.",
},
})
.input(z.object({ applicationId: z.string(), traefikConfig: z.string() }))
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -872,6 +1029,12 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
readAppMonitoring: withPermission("monitoring", "read")
.meta({
openapi: {
summary: "Read application monitoring stats",
description: "Retrieves CPU and memory monitoring statistics for the application. Only available in self-hosted mode.",
},
})
.input(apiFindMonitoringStats)
.query(async ({ input }) => {
if (IS_CLOUD) {
@@ -885,6 +1048,12 @@ export const applicationRouter = createTRPCRouter({
return stats;
}),
move: protectedProcedure
.meta({
openapi: {
summary: "Move application to another environment",
description: "Moves an application to a different environment within the same project or to another project's environment.",
},
})
.input(
z.object({
applicationId: z.string(),
@@ -921,6 +1090,12 @@ export const applicationRouter = createTRPCRouter({
}),
cancelDeployment: protectedProcedure
.meta({
openapi: {
summary: "Cancel a deployment",
description: "Cancels an in-progress deployment for the application and resets its status to idle. Only available in cloud version.",
},
})
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -971,6 +1146,12 @@ export const applicationRouter = createTRPCRouter({
}),
search: protectedProcedure
.meta({
openapi: {
summary: "Search applications",
description: "Searches applications by name, appName, description, repository, owner, or Docker image with pagination. Respects service-level access control.",
},
})
.input(
z.object({
q: z.string().optional(),
@@ -1101,4 +1282,45 @@ export const applicationRouter = createTRPCRouter({
total: countResult[0]?.count ?? 0,
};
}),
readLogs: protectedProcedure
.meta({
openapi: {
summary: "Read application logs",
description: "Retrieves Docker container logs for the application with configurable tail length, time range, and optional text search filtering.",
},
})
.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

@@ -78,6 +78,12 @@ interface RcloneFile {
export const backupRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a backup",
description: "Creates a new backup configuration for a database or compose service. If enabled, automatically schedules the backup according to the provided cron expression.",
},
})
.input(apiCreateBackup)
.mutation(async ({ input, ctx }) => {
try {
@@ -152,6 +158,12 @@ export const backupRouter = createTRPCRouter({
}
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a backup",
description: "Returns the details of a specific backup configuration by its ID.",
},
})
.input(apiFindOneBackup)
.query(async ({ input, ctx }) => {
const backup = await findBackupById(input.backupId);
@@ -172,6 +184,12 @@ export const backupRouter = createTRPCRouter({
return backup;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a backup",
description: "Updates an existing backup configuration. Reschedules or removes the backup job depending on the enabled state.",
},
})
.input(apiUpdateBackup)
.mutation(async ({ input, ctx }) => {
try {
@@ -229,6 +247,12 @@ export const backupRouter = createTRPCRouter({
}
}),
remove: protectedProcedure
.meta({
openapi: {
summary: "Delete a backup",
description: "Permanently removes a backup configuration and unschedules any associated backup job.",
},
})
.input(apiRemoveBackup)
.mutation(async ({ input, ctx }) => {
try {
@@ -272,6 +296,12 @@ export const backupRouter = createTRPCRouter({
}
}),
manualBackupPostgres: protectedProcedure
.meta({
openapi: {
summary: "Run a PostgreSQL backup manually",
description: "Immediately executes a PostgreSQL backup using the specified backup configuration. Cleans up old backups according to retention settings.",
},
})
.input(apiFindOneBackup)
.mutation(async ({ input, ctx }) => {
try {
@@ -303,6 +333,12 @@ export const backupRouter = createTRPCRouter({
}),
manualBackupMySql: protectedProcedure
.meta({
openapi: {
summary: "Run a MySQL backup manually",
description: "Immediately executes a MySQL backup using the specified backup configuration. Cleans up old backups according to retention settings.",
},
})
.input(apiFindOneBackup)
.mutation(async ({ input, ctx }) => {
try {
@@ -330,6 +366,12 @@ export const backupRouter = createTRPCRouter({
}
}),
manualBackupMariadb: protectedProcedure
.meta({
openapi: {
summary: "Run a MariaDB backup manually",
description: "Immediately executes a MariaDB backup using the specified backup configuration. Cleans up old backups according to retention settings.",
},
})
.input(apiFindOneBackup)
.mutation(async ({ input, ctx }) => {
try {
@@ -357,6 +399,12 @@ export const backupRouter = createTRPCRouter({
}
}),
manualBackupCompose: protectedProcedure
.meta({
openapi: {
summary: "Run a Compose backup manually",
description: "Immediately executes a Compose service backup using the specified backup configuration. Cleans up old backups according to retention settings.",
},
})
.input(apiFindOneBackup)
.mutation(async ({ input, ctx }) => {
try {
@@ -384,6 +432,12 @@ export const backupRouter = createTRPCRouter({
}
}),
manualBackupMongo: protectedProcedure
.meta({
openapi: {
summary: "Run a MongoDB backup manually",
description: "Immediately executes a MongoDB backup using the specified backup configuration. Cleans up old backups according to retention settings.",
},
})
.input(apiFindOneBackup)
.mutation(async ({ input, ctx }) => {
try {
@@ -411,6 +465,12 @@ export const backupRouter = createTRPCRouter({
}
}),
manualBackupLibsql: protectedProcedure
.meta({
openapi: {
summary: "Run a LibSQL backup manually",
description: "Immediately executes a LibSQL backup using the specified backup configuration. Cleans up old backups according to retention settings.",
},
})
.input(apiFindOneBackup)
.mutation(async ({ input, ctx }) => {
try {
@@ -438,6 +498,12 @@ export const backupRouter = createTRPCRouter({
}
}),
manualBackupWebServer: withPermission("backup", "create")
.meta({
openapi: {
summary: "Run a web server backup manually",
description: "Immediately executes a web server backup using the specified backup configuration. Cleans up old backups according to retention settings.",
},
})
.input(apiFindOneBackup)
.mutation(async ({ input, ctx }) => {
const backup = await findBackupById(input.backupId);
@@ -451,6 +517,12 @@ export const backupRouter = createTRPCRouter({
return true;
}),
listBackupFiles: withPermission("backup", "read")
.meta({
openapi: {
summary: "List backup files in S3",
description: "Lists backup files stored in the S3 destination bucket. Supports searching by path prefix and returns up to 100 results.",
},
})
.input(
z.object({
destinationId: z.string(),

View File

@@ -25,6 +25,12 @@ import {
export const bitbucketRouter = createTRPCRouter({
create: withPermission("gitProviders", "create")
.meta({
openapi: {
summary: "Create Bitbucket provider",
description: "Creates a new Bitbucket provider configuration linked to the active organization. Requires gitProviders create permission.",
},
})
.input(apiCreateBitbucket)
.mutation(async ({ input, ctx }) => {
try {
@@ -50,11 +56,24 @@ export const bitbucketRouter = createTRPCRouter({
}
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get Bitbucket provider",
description: "Returns a single Bitbucket provider configuration by its ID.",
},
})
.input(apiFindOneBitbucket)
.query(async ({ input }) => {
return await findBitbucketById(input.bitbucketId);
}),
bitbucketProviders: protectedProcedure.query(async ({ ctx }) => {
bitbucketProviders: protectedProcedure
.meta({
openapi: {
summary: "List Bitbucket providers",
description: "Returns all Bitbucket providers accessible to the current user within the active organization.",
},
})
.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
let result = await db.query.bitbucket.findMany({
@@ -77,16 +96,34 @@ export const bitbucketRouter = createTRPCRouter({
}),
getBitbucketRepositories: protectedProcedure
.meta({
openapi: {
summary: "List Bitbucket repositories",
description: "Fetches the list of repositories accessible by the Bitbucket provider. Calls the Bitbucket API using the provider's credentials.",
},
})
.input(apiFindOneBitbucket)
.query(async ({ input }) => {
return await getBitbucketRepositories(input.bitbucketId);
}),
getBitbucketBranches: protectedProcedure
.meta({
openapi: {
summary: "List Bitbucket branches",
description: "Fetches the list of branches for a specific Bitbucket repository. Calls the Bitbucket API using the provider's credentials.",
},
})
.input(apiFindBitbucketBranches)
.query(async ({ input }) => {
return await getBitbucketBranches(input);
}),
testConnection: protectedProcedure
.meta({
openapi: {
summary: "Test Bitbucket connection",
description: "Tests the connection to a Bitbucket provider by attempting to fetch its repositories. Returns the number of repositories found or throws an error on failure.",
},
})
.input(apiBitbucketTestConnection)
.mutation(async ({ input }) => {
try {
@@ -101,6 +138,12 @@ export const bitbucketRouter = createTRPCRouter({
}
}),
update: withPermission("gitProviders", "create")
.meta({
openapi: {
summary: "Update Bitbucket provider",
description: "Updates a Bitbucket provider configuration. Requires gitProviders create permission.",
},
})
.input(apiUpdateBitbucket)
.mutation(async ({ input, ctx }) => {
const result = await updateBitbucket(input.bitbucketId, {

View File

@@ -19,6 +19,12 @@ import {
export const certificateRouter = createTRPCRouter({
create: withPermission("certificate", "create")
.meta({
openapi: {
summary: "Create a certificate",
description: "Creates a new SSL/TLS certificate. In cloud mode, a server must be specified. Logs an audit entry upon creation.",
},
})
.input(apiCreateCertificate)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD && !input.serverId) {
@@ -41,6 +47,12 @@ export const certificateRouter = createTRPCRouter({
}),
one: withPermission("certificate", "read")
.meta({
openapi: {
summary: "Get a certificate",
description: "Returns a single certificate by its ID. Verifies that the certificate belongs to the current organization.",
},
})
.input(apiFindCertificate)
.query(async ({ input, ctx }) => {
const certificates = await findCertificateById(input.certificateId);
@@ -53,6 +65,12 @@ export const certificateRouter = createTRPCRouter({
return certificates;
}),
remove: withPermission("certificate", "delete")
.meta({
openapi: {
summary: "Delete a certificate",
description: "Deletes a certificate by its ID after verifying organization ownership. Logs an audit entry before removal.",
},
})
.input(apiFindCertificate)
.mutation(async ({ input, ctx }) => {
const certificates = await findCertificateById(input.certificateId);
@@ -71,7 +89,14 @@ export const certificateRouter = createTRPCRouter({
await removeCertificateById(input.certificateId);
return true;
}),
all: withPermission("certificate", "read").query(async ({ ctx }) => {
all: withPermission("certificate", "read")
.meta({
openapi: {
summary: "List all certificates",
description: "Returns all certificates belonging to the current organization, including their associated server information.",
},
})
.query(async ({ ctx }) => {
return await db.query.certificates.findMany({
where: eq(certificates.organizationId, ctx.session.activeOrganizationId),
with: {
@@ -80,6 +105,12 @@ export const certificateRouter = createTRPCRouter({
});
}),
update: withPermission("certificate", "update")
.meta({
openapi: {
summary: "Update a certificate",
description: "Updates the name, certificate data, and private key of an existing certificate. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdateCertificate)
.mutation(async ({ input, ctx }) => {
const certificate = await findCertificateById(input.certificateId);

View File

@@ -13,6 +13,12 @@ import { createTRPCRouter, withPermission } from "../trpc";
export const clusterRouter = createTRPCRouter({
getNodes: withPermission("server", "read")
.meta({
openapi: {
summary: "Get cluster nodes",
description: "Retrieves all nodes in the Docker Swarm cluster. Optionally targets a remote server.",
},
})
.input(
z.object({
serverId: z.string().optional(),
@@ -25,6 +31,12 @@ export const clusterRouter = createTRPCRouter({
}),
removeWorker: withPermission("server", "delete")
.meta({
openapi: {
summary: "Remove a worker node",
description: "Drains and forcefully removes a worker node from the Docker Swarm cluster. An audit log entry is created for the removal.",
},
})
.input(
z.object({
nodeId: z.string(),
@@ -60,6 +72,12 @@ export const clusterRouter = createTRPCRouter({
}),
addWorker: withPermission("server", "create")
.meta({
openapi: {
summary: "Get worker join command",
description: "Returns the Docker Swarm join command and token for adding a new worker node to the cluster, along with the Docker version.",
},
})
.input(
z.object({
serverId: z.string().optional(),
@@ -83,6 +101,12 @@ export const clusterRouter = createTRPCRouter({
}),
addManager: withPermission("server", "create")
.meta({
openapi: {
summary: "Get manager join command",
description: "Returns the Docker Swarm join command and token for adding a new manager node to the cluster, along with the Docker version.",
},
})
.input(
z.object({
serverId: z.string().optional(),

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 {
@@ -61,6 +62,7 @@ import {
apiFindCompose,
apiRandomizeCompose,
apiRedeployCompose,
apiSaveEnvironmentVariablesCompose,
apiUpdateCompose,
compose as composeTable,
environments,
@@ -81,6 +83,12 @@ import { audit } from "../utils/audit";
export const composeRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a compose service",
description: "Creates a new Docker Compose service in the specified project environment with the given configuration.",
},
})
.input(apiCreateCompose)
.mutation(async ({ ctx, input }) => {
try {
@@ -131,6 +139,12 @@ export const composeRouter = createTRPCRouter({
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a compose service",
description: "Retrieves detailed information about a compose service by its ID, including git provider access status and deployment configuration.",
},
})
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.composeId, "read");
@@ -187,6 +201,12 @@ export const composeRouter = createTRPCRouter({
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a compose service",
description: "Updates the configuration of a compose service such as name, description, compose file content, and other settings.",
},
})
.input(apiUpdateCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -201,7 +221,44 @@ export const composeRouter = createTRPCRouter({
});
return updated;
}),
saveEnvironment: protectedProcedure
.meta({
openapi: {
summary: "Save compose environment variables",
description: "Updates the environment variables for a compose service.",
},
})
.input(apiSaveEnvironmentVariablesCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
envVars: ["write"],
});
const updated = await updateCompose(input.composeId, {
env: input.env,
});
if (!updated) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error adding environment variables",
});
}
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: updated?.name,
});
return true;
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a compose service",
description: "Permanently deletes a compose service and cleans up associated Docker resources, deployments, and directories. Optionally deletes associated volumes.",
},
})
.input(apiDeleteCompose)
.mutation(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.composeId, "delete");
@@ -252,6 +309,12 @@ export const composeRouter = createTRPCRouter({
return composeResult;
}),
cleanQueues: protectedProcedure
.meta({
openapi: {
summary: "Clean deployment queues",
description: "Removes all pending deployment jobs from the queue for the specified compose service.",
},
})
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -261,6 +324,12 @@ export const composeRouter = createTRPCRouter({
return { success: true, message: "Queues cleaned successfully" };
}),
clearDeployments: protectedProcedure
.meta({
openapi: {
summary: "Clear old deployments",
description: "Removes old deployment logs and artifacts for the compose service to free up disk space.",
},
})
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -277,6 +346,12 @@ export const composeRouter = createTRPCRouter({
return true;
}),
killBuild: protectedProcedure
.meta({
openapi: {
summary: "Kill active build",
description: "Forcefully terminates the currently running Docker build process for the compose service.",
},
})
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -287,14 +362,26 @@ export const composeRouter = createTRPCRouter({
}),
loadServices: protectedProcedure
.meta({
openapi: {
summary: "Load compose services",
description: "Parses the compose file and returns the list of services defined in it, with their current container status.",
},
})
.input(apiFetchServices)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
service: ["read"],
});
return await loadServices(input.composeId, input.type);
}),
loadMountsByService: protectedProcedure
.meta({
openapi: {
summary: "Load mounts by service",
description: "Retrieves the Docker volume mounts for a specific service within a compose stack by inspecting the running container.",
},
})
.input(
z.object({
composeId: z.string().min(1),
@@ -313,6 +400,12 @@ export const composeRouter = createTRPCRouter({
return mounts;
}),
fetchSourceType: protectedProcedure
.meta({
openapi: {
summary: "Fetch and clone source",
description: "Clones the compose repository from the configured git provider and returns the source type. Executes the clone command locally or on a remote server.",
},
})
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
try {
@@ -338,6 +431,12 @@ export const composeRouter = createTRPCRouter({
}),
randomizeCompose: protectedProcedure
.meta({
openapi: {
summary: "Randomize compose file",
description: "Adds a random suffix to service names and volumes in the compose file to avoid naming conflicts between deployments.",
},
})
.input(apiRandomizeCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -354,6 +453,12 @@ export const composeRouter = createTRPCRouter({
return result;
}),
isolatedDeployment: protectedProcedure
.meta({
openapi: {
summary: "Randomize for isolated deployment",
description: "Randomizes the compose file for isolated deployment mode, ensuring unique service and volume names to support parallel deployments.",
},
})
.input(apiRandomizeCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -373,6 +478,12 @@ export const composeRouter = createTRPCRouter({
return result;
}),
getConvertedCompose: protectedProcedure
.meta({
openapi: {
summary: "Get converted compose file",
description: "Returns the compose file with domains injected as Traefik labels, converted to YAML format ready for deployment.",
},
})
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -387,6 +498,12 @@ export const composeRouter = createTRPCRouter({
}),
deploy: protectedProcedure
.meta({
openapi: {
summary: "Deploy a compose service",
description: "Triggers a new deployment for the compose service. Queues a deployment job or executes it directly for cloud servers.",
},
})
.input(apiDeployCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -437,6 +554,12 @@ export const composeRouter = createTRPCRouter({
};
}),
redeploy: protectedProcedure
.meta({
openapi: {
summary: "Redeploy a compose service",
description: "Triggers a rebuild and redeployment of the compose service. Queues a deployment job or executes it directly for cloud servers.",
},
})
.input(apiRedeployCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -485,6 +608,12 @@ export const composeRouter = createTRPCRouter({
};
}),
stop: protectedProcedure
.meta({
openapi: {
summary: "Stop a compose service",
description: "Stops all running containers for the compose service using docker compose stop.",
},
})
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -501,6 +630,12 @@ export const composeRouter = createTRPCRouter({
return true;
}),
start: protectedProcedure
.meta({
openapi: {
summary: "Start a compose service",
description: "Starts all containers for the compose service using docker compose start.",
},
})
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -517,6 +652,12 @@ export const composeRouter = createTRPCRouter({
return true;
}),
getDefaultCommand: protectedProcedure
.meta({
openapi: {
summary: "Get default compose command",
description: "Generates and returns the default docker compose command that would be used to deploy the service.",
},
})
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -527,6 +668,12 @@ export const composeRouter = createTRPCRouter({
return `docker ${command}`;
}),
refreshToken: protectedProcedure
.meta({
openapi: {
summary: "Refresh deploy token",
description: "Regenerates the webhook refresh token for the compose service, invalidating the previous token used for triggering deployments.",
},
})
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -545,6 +692,12 @@ export const composeRouter = createTRPCRouter({
return true;
}),
deployTemplate: protectedProcedure
.meta({
openapi: {
summary: "Deploy a template",
description: "Creates a new compose service from a template by fetching its files, processing variables, creating mounts and domains, and setting up the compose configuration.",
},
})
.input(
z.object({
environmentId: z.string(),
@@ -653,6 +806,12 @@ export const composeRouter = createTRPCRouter({
}),
templates: protectedProcedure
.meta({
openapi: {
summary: "List available templates",
description: "Fetches the list of available compose templates from the GitHub templates repository.",
},
})
.input(z.object({ baseUrl: z.string().optional() }))
.query(async ({ input }) => {
try {
@@ -671,6 +830,12 @@ export const composeRouter = createTRPCRouter({
}),
getTags: protectedProcedure
.meta({
openapi: {
summary: "Get template tags",
description: "Fetches all unique tags from the available compose templates for filtering purposes.",
},
})
.input(z.object({ baseUrl: z.string().optional() }))
.query(async ({ input }) => {
const githubTemplates = await fetchTemplatesList(input.baseUrl);
@@ -680,6 +845,12 @@ export const composeRouter = createTRPCRouter({
return uniqueTags;
}),
disconnectGitProvider: protectedProcedure
.meta({
openapi: {
summary: "Disconnect git provider",
description: "Removes all git provider configuration from the compose service, resetting source type to default and clearing repository, branch, and owner fields for all providers.",
},
})
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -732,6 +903,12 @@ export const composeRouter = createTRPCRouter({
}),
move: protectedProcedure
.meta({
openapi: {
summary: "Move compose to another environment",
description: "Moves a compose service to a different environment within the same project or to another project's environment.",
},
})
.input(
z.object({
composeId: z.string(),
@@ -769,6 +946,12 @@ export const composeRouter = createTRPCRouter({
}),
processTemplate: protectedProcedure
.meta({
openapi: {
summary: "Process a template",
description: "Processes a base64-encoded template configuration, resolving variables and generating the compose file and environment settings without applying them.",
},
})
.input(
z.object({
base64: z.string(),
@@ -833,6 +1016,12 @@ export const composeRouter = createTRPCRouter({
}),
import: protectedProcedure
.meta({
openapi: {
summary: "Import a template",
description: "Imports a base64-encoded template into an existing compose service, replacing its compose file, environment variables, mounts, and domains with the template's configuration.",
},
})
.input(
z.object({
base64: z.string(),
@@ -945,6 +1134,12 @@ export const composeRouter = createTRPCRouter({
}),
cancelDeployment: protectedProcedure
.meta({
openapi: {
summary: "Cancel a deployment",
description: "Cancels an in-progress deployment for the compose service and resets its status to idle. Only available in cloud version.",
},
})
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -998,6 +1193,12 @@ export const composeRouter = createTRPCRouter({
}),
search: protectedProcedure
.meta({
openapi: {
summary: "Search compose services",
description: "Searches compose services by name, appName, or description with pagination. Respects service-level access control.",
},
})
.input(
z.object({
q: z.string().optional(),
@@ -1104,4 +1305,50 @@ export const composeRouter = createTRPCRouter({
total: countResult[0]?.count ?? 0,
};
}),
readLogs: protectedProcedure
.meta({
openapi: {
summary: "Read compose container logs",
description: "Retrieves Docker container logs for a specific container within the compose service with configurable tail length, time range, and optional text search filtering.",
},
})
.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

@@ -34,6 +34,12 @@ import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const deploymentRouter = createTRPCRouter({
all: protectedProcedure
.meta({
openapi: {
summary: "List deployments by application",
description: "Returns all deployments associated with the given application, ordered by creation date.",
},
})
.input(apiFindAllByApplication)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -43,6 +49,12 @@ export const deploymentRouter = createTRPCRouter({
}),
allByCompose: protectedProcedure
.meta({
openapi: {
summary: "List deployments by compose",
description: "Returns all deployments associated with the given compose service.",
},
})
.input(apiFindAllByCompose)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -51,11 +63,24 @@ export const deploymentRouter = createTRPCRouter({
return await findAllDeploymentsByComposeId(input.composeId);
}),
allByServer: withPermission("deployment", "read")
.meta({
openapi: {
summary: "List deployments by server",
description: "Returns all deployments associated with the given server.",
},
})
.input(apiFindAllByServer)
.query(async ({ input }) => {
return await findAllDeploymentsByServerId(input.serverId);
}),
allCentralized: withPermission("deployment", "read").query(
allCentralized: withPermission("deployment", "read")
.meta({
openapi: {
summary: "List all deployments centralized",
description: "Returns all deployments across all services in the organization. Non-admin users only see deployments for their accessible services.",
},
})
.query(
async ({ ctx }) => {
const orgId = ctx.session.activeOrganizationId;
const accessedServices =
@@ -69,7 +94,14 @@ export const deploymentRouter = createTRPCRouter({
},
),
queueList: withPermission("deployment", "read").query(async ({ ctx }) => {
queueList: withPermission("deployment", "read")
.meta({
openapi: {
summary: "List deployment queue jobs",
description: "Returns all jobs in the deployment queue with their current state, timestamps, and resolved service paths.",
},
})
.query(async ({ ctx }) => {
const orgId = ctx.session.activeOrganizationId;
let rows: QueueJobRow[];
@@ -116,6 +148,12 @@ export const deploymentRouter = createTRPCRouter({
}),
allByType: protectedProcedure
.meta({
openapi: {
summary: "List deployments by service type",
description: "Returns all deployments for a given service ID and type (application, compose, etc.), including associated rollback information.",
},
})
.input(apiFindAllByType)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
@@ -131,6 +169,12 @@ export const deploymentRouter = createTRPCRouter({
return deploymentsList;
}),
killProcess: protectedProcedure
.meta({
openapi: {
summary: "Cancel a running deployment",
description: "Kills the running process of a deployment by sending SIGKILL to its PID. Updates the deployment status to error.",
},
})
.input(
z.object({
deploymentId: z.string().min(1),
@@ -168,6 +212,12 @@ export const deploymentRouter = createTRPCRouter({
}),
removeDeployment: protectedProcedure
.meta({
openapi: {
summary: "Delete a deployment",
description: "Permanently removes a deployment record and its associated data.",
},
})
.input(
z.object({
deploymentId: z.string().min(1),
@@ -189,4 +239,43 @@ export const deploymentRouter = createTRPCRouter({
});
return result;
}),
readBuildLogs: protectedProcedure
.meta({
openapi: {
summary: "Read deployment build logs",
description:
"Reads the build/deployment log file for a specific deployment. Returns the last N lines (default 200). Works for both local and remote server deployments.",
},
})
.input(
z.object({
deploymentId: z.string().min(1),
tail: z.number().int().min(1).max(10000).default(200),
}),
)
.query(async ({ input, ctx }) => {
const deployment = await findDeploymentById(input.deploymentId);
const serviceId = deployment.applicationId || deployment.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["read"],
});
}
const command = `tail -n ${input.tail} ${deployment.logPath} 2>/dev/null || echo "Log file not found"`;
const { stdout } = deployment.serverId
? await execAsyncRemote(deployment.serverId, command)
: await execAsync(command);
return {
deploymentId: deployment.deploymentId,
status: deployment.status,
errorMessage: deployment.errorMessage || null,
title: deployment.title,
createdAt: deployment.createdAt,
logs: stdout,
};
}),
});

View File

@@ -22,6 +22,12 @@ import {
export const destinationRouter = createTRPCRouter({
create: withPermission("destination", "create")
.meta({
openapi: {
summary: "Create backup destination",
description: "Creates a new S3-compatible backup destination for the current organization and logs an audit event.",
},
})
.input(apiCreateDestination)
.mutation(async ({ input, ctx }) => {
try {
@@ -45,6 +51,12 @@ export const destinationRouter = createTRPCRouter({
}
}),
testConnection: withPermission("destination", "create")
.meta({
openapi: {
summary: "Test backup destination connection",
description: "Tests connectivity to an S3-compatible bucket using rclone. Runs locally or on a remote server depending on configuration.",
},
})
.input(apiCreateDestination)
.mutation(async ({ input }) => {
const {
@@ -102,6 +114,12 @@ export const destinationRouter = createTRPCRouter({
}
}),
one: withPermission("destination", "read")
.meta({
openapi: {
summary: "Get backup destination",
description: "Returns a single backup destination by ID. Verifies the caller belongs to the same organization.",
},
})
.input(apiFindOneDestination)
.query(async ({ input, ctx }) => {
const destination = await findDestinationById(input.destinationId);
@@ -113,13 +131,26 @@ export const destinationRouter = createTRPCRouter({
}
return destination;
}),
all: withPermission("destination", "read").query(async ({ ctx }) => {
all: withPermission("destination", "read")
.meta({
openapi: {
summary: "List all backup destinations",
description: "Returns all S3-compatible backup destinations for the current organization, ordered by creation date descending.",
},
})
.query(async ({ ctx }) => {
return await db.query.destinations.findMany({
where: eq(destinations.organizationId, ctx.session.activeOrganizationId),
orderBy: [desc(destinations.createdAt)],
});
}),
remove: withPermission("destination", "delete")
.meta({
openapi: {
summary: "Delete backup destination",
description: "Removes a backup destination by ID. Verifies organization ownership and logs an audit event before deletion.",
},
})
.input(apiRemoveDestination)
.mutation(async ({ input, ctx }) => {
try {
@@ -147,6 +178,12 @@ export const destinationRouter = createTRPCRouter({
}
}),
update: withPermission("destination", "create")
.meta({
openapi: {
summary: "Update backup destination",
description: "Updates an existing backup destination. Verifies organization ownership before applying changes and logs an audit event.",
},
})
.input(apiUpdateDestination)
.mutation(async ({ input, ctx }) => {
try {

View File

@@ -20,6 +20,12 @@ export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
export const dockerRouter = createTRPCRouter({
getContainers: withPermission("docker", "read")
.meta({
openapi: {
summary: "Get Docker containers",
description: "Retrieves a list of all Docker containers. Optionally targets a specific remote server by ID.",
},
})
.input(
z.object({
serverId: z.string().optional(),
@@ -36,6 +42,12 @@ export const dockerRouter = createTRPCRouter({
}),
restartContainer: withPermission("docker", "read")
.meta({
openapi: {
summary: "Restart a Docker container",
description: "Restarts a Docker container by its ID. An audit log entry is created for the action.",
},
})
.input(
z.object({
containerId: z
@@ -56,6 +68,12 @@ export const dockerRouter = createTRPCRouter({
}),
removeContainer: withPermission("docker", "read")
.meta({
openapi: {
summary: "Remove a Docker container",
description: "Removes a Docker container by its ID. Optionally targets a remote server. An audit log entry is created for the deletion.",
},
})
.input(
z.object({
containerId: z
@@ -82,6 +100,12 @@ export const dockerRouter = createTRPCRouter({
}),
getConfig: withPermission("docker", "read")
.meta({
openapi: {
summary: "Get Docker container configuration",
description: "Retrieves the configuration (inspect data) for a specific Docker container. Optionally targets a remote server.",
},
})
.input(
z.object({
containerId: z
@@ -102,6 +126,12 @@ export const dockerRouter = createTRPCRouter({
}),
getContainersByAppNameMatch: withPermission("service", "read")
.meta({
openapi: {
summary: "Get containers by app name match",
description: "Retrieves containers whose names match the given application name. Supports filtering by app type (stack or docker-compose) and optionally targets a remote server.",
},
})
.input(
z.object({
appType: z.enum(["stack", "docker-compose"]).optional(),
@@ -124,6 +154,12 @@ export const dockerRouter = createTRPCRouter({
}),
getContainersByAppLabel: withPermission("docker", "read")
.meta({
openapi: {
summary: "Get containers by app label",
description: "Retrieves containers filtered by application label. Supports standalone and swarm deployment types, and optionally targets a remote server.",
},
})
.input(
z.object({
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
@@ -146,6 +182,12 @@ export const dockerRouter = createTRPCRouter({
}),
getStackContainersByAppName: withPermission("docker", "read")
.meta({
openapi: {
summary: "Get stack containers by app name",
description: "Retrieves all containers belonging to a Docker stack by application name. Optionally targets a remote server.",
},
})
.input(
z.object({
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
@@ -163,6 +205,12 @@ export const dockerRouter = createTRPCRouter({
}),
getServiceContainersByAppName: withPermission("docker", "read")
.meta({
openapi: {
summary: "Get service containers by app name",
description: "Retrieves all containers belonging to a Docker Swarm service by application name. Optionally targets a remote server.",
},
})
.input(
z.object({
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
@@ -180,6 +228,12 @@ export const dockerRouter = createTRPCRouter({
}),
uploadFileToContainer: withPermission("docker", "read")
.meta({
openapi: {
summary: "Upload a file to a Docker container",
description: "Uploads a file to a specified path inside a Docker container. The file is converted to a buffer and transferred to the container's filesystem.",
},
})
.input(uploadFileToContainerSchema)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {

View File

@@ -33,6 +33,12 @@ import {
export const domainRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a domain",
description: "Creates a new domain for an application or compose service. Validates permissions and logs an audit entry.",
},
})
.input(apiCreateDomain)
.mutation(async ({ input, ctx }) => {
try {
@@ -65,6 +71,12 @@ export const domainRouter = createTRPCRouter({
}
}),
byApplicationId: protectedProcedure
.meta({
openapi: {
summary: "List domains by application",
description: "Returns all domains associated with a given application ID.",
},
})
.input(apiFindOneApplication)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -73,6 +85,12 @@ export const domainRouter = createTRPCRouter({
return await findDomainsByApplicationId(input.applicationId);
}),
byComposeId: protectedProcedure
.meta({
openapi: {
summary: "List domains by compose service",
description: "Returns all domains associated with a given compose service ID.",
},
})
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
@@ -81,6 +99,12 @@ export const domainRouter = createTRPCRouter({
return await findDomainsByComposeId(input.composeId);
}),
generateDomain: withPermission("domain", "create")
.meta({
openapi: {
summary: "Generate a traefik.me domain",
description: "Generates a free traefik.me domain for an application, using the server IP to create a wildcard subdomain.",
},
})
.input(z.object({ appName: z.string(), serverId: z.string().optional() }))
.mutation(async ({ input, ctx }) => {
return generateTraefikMeDomain(
@@ -90,6 +114,12 @@ export const domainRouter = createTRPCRouter({
);
}),
canGenerateTraefikMeDomains: withPermission("domain", "read")
.meta({
openapi: {
summary: "Check traefik.me domain availability",
description: "Checks whether traefik.me domains can be generated by returning the server IP address. Returns the IP from the server record or web server settings.",
},
})
.input(z.object({ serverId: z.string() }))
.query(async ({ input }) => {
if (input.serverId) {
@@ -101,6 +131,12 @@ export const domainRouter = createTRPCRouter({
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a domain",
description: "Updates a domain's configuration and refreshes the Traefik routing rules for the associated application or preview deployment.",
},
})
.input(apiUpdateDomain)
.mutation(async ({ input, ctx }) => {
const currentDomain = await findDomainById(input.domainId);
@@ -141,7 +177,15 @@ export const domainRouter = createTRPCRouter({
}
return result;
}),
one: protectedProcedure.input(apiFindDomain).query(async ({ input, ctx }) => {
one: protectedProcedure
.meta({
openapi: {
summary: "Get a domain",
description: "Returns a single domain by its ID. Validates read permissions against the associated service or preview deployment.",
},
})
.input(apiFindDomain)
.query(async ({ input, ctx }) => {
const domain = await findDomainById(input.domainId);
const serviceId = domain.applicationId || domain.composeId;
if (serviceId) {
@@ -159,6 +203,12 @@ export const domainRouter = createTRPCRouter({
return domain;
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a domain",
description: "Deletes a domain by its ID and removes the associated Traefik routing configuration for the application.",
},
})
.input(apiFindDomain)
.mutation(async ({ input, ctx }) => {
const domain = await findDomainById(input.domainId);
@@ -193,6 +243,12 @@ export const domainRouter = createTRPCRouter({
}),
validateDomain: withPermission("domain", "read")
.meta({
openapi: {
summary: "Validate a domain",
description: "Checks whether a domain's DNS records are correctly configured, optionally verifying against a specific server IP.",
},
})
.input(
z.object({
domain: z.string(),

View File

@@ -63,6 +63,12 @@ const filterEnvironmentServices = (
export const environmentRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create environment",
description: "Creates a new environment within a project. The name 'production' is reserved and cannot be used. Checks creation permissions and logs an audit event.",
},
})
.input(apiCreateEnvironment)
.mutation(async ({ input, ctx }) => {
try {
@@ -99,6 +105,12 @@ export const environmentRouter = createTRPCRouter({
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get environment",
description: "Returns a single environment by ID with all its services. Non-admin users only see services they have been granted access to.",
},
})
.input(apiFindOneEnvironment)
.query(async ({ input, ctx }) => {
const environment = await findEnvironmentById(input.environmentId);
@@ -137,6 +149,12 @@ export const environmentRouter = createTRPCRouter({
}),
byProjectId: protectedProcedure
.meta({
openapi: {
summary: "List environments by project",
description: "Returns all environments for a given project. Non-admin users only see environments and services they have been granted access to.",
},
})
.input(z.object({ projectId: z.string() }))
.query(async ({ input, ctx }) => {
try {
@@ -183,6 +201,12 @@ export const environmentRouter = createTRPCRouter({
}),
remove: protectedProcedure
.meta({
openapi: {
summary: "Delete environment",
description: "Deletes an environment by ID. The default environment cannot be deleted. Checks deletion permissions and environment access before removing.",
},
})
.input(apiRemoveEnvironment)
.mutation(async ({ input, ctx }) => {
try {
@@ -229,6 +253,12 @@ export const environmentRouter = createTRPCRouter({
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update environment",
description: "Updates an environment's name, description, or env variables. The default environment cannot be renamed. Checks environment access and env-var write permissions.",
},
})
.input(apiUpdateEnvironment)
.mutation(async ({ input, ctx }) => {
try {
@@ -296,6 +326,12 @@ export const environmentRouter = createTRPCRouter({
}),
duplicate: protectedProcedure
.meta({
openapi: {
summary: "Duplicate environment",
description: "Creates a copy of an existing environment including its services. Checks environment access and organization ownership before duplicating.",
},
})
.input(apiDuplicateEnvironment)
.mutation(async ({ input, ctx }) => {
try {
@@ -343,6 +379,12 @@ export const environmentRouter = createTRPCRouter({
}),
search: protectedProcedure
.meta({
openapi: {
summary: "Search environments",
description: "Searches environments by name, description, or project with pagination. Non-admin users only see environments they have been granted access to.",
},
})
.input(
z.object({
q: z.string().optional(),

View File

@@ -21,7 +21,14 @@ import {
} from "@/server/db/schema";
export const gitProviderRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
getAll: protectedProcedure
.meta({
openapi: {
summary: "List all git providers",
description: "Returns all git providers (GitHub, GitLab, Bitbucket, Gitea) accessible to the current user within the active organization, ordered by creation date.",
},
})
.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
if (accessibleIds.size === 0) {
@@ -46,6 +53,12 @@ export const gitProviderRouter = createTRPCRouter({
}),
toggleShare: protectedProcedure
.meta({
openapi: {
summary: "Toggle git provider sharing",
description: "Toggles whether a git provider is shared with the entire organization. Only the owner of the provider can change this setting.",
},
})
.input(apiToggleShareGitProvider)
.mutation(async ({ input, ctx }) => {
const provider = await findGitProviderById(input.gitProviderId);
@@ -73,6 +86,12 @@ export const gitProviderRouter = createTRPCRouter({
}),
allForPermissions: withPermission("member", "update")
.meta({
openapi: {
summary: "List git providers for permissions",
description: "Returns a minimal list of all git providers in the organization for use in permission assignment UIs. Requires a valid enterprise license and member update permission.",
},
})
.use(async ({ ctx, next }) => {
const licensed = await hasValidLicense(ctx.session.activeOrganizationId);
if (!licensed) {
@@ -96,6 +115,12 @@ export const gitProviderRouter = createTRPCRouter({
}),
remove: withPermission("gitProviders", "delete")
.meta({
openapi: {
summary: "Remove git provider",
description: "Deletes a git provider from the organization. Requires gitProviders delete permission and the provider must belong to the active organization.",
},
})
.input(apiRemoveGitProvider)
.mutation(async ({ input, ctx }) => {
try {

View File

@@ -27,6 +27,12 @@ import {
export const giteaRouter = createTRPCRouter({
create: withPermission("gitProviders", "create")
.meta({
openapi: {
summary: "Create Gitea provider",
description: "Creates a new Gitea provider configuration linked to the active organization. Requires gitProviders create permission.",
},
})
.input(apiCreateGitea)
.mutation(async ({ input, ctx }) => {
try {
@@ -53,11 +59,26 @@ export const giteaRouter = createTRPCRouter({
}
}),
one: protectedProcedure.input(apiFindOneGitea).query(async ({ input }) => {
return await findGiteaById(input.giteaId);
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get Gitea provider",
description: "Returns a single Gitea provider configuration by its ID.",
},
})
.input(apiFindOneGitea)
.query(async ({ input }) => {
return await findGiteaById(input.giteaId);
}),
giteaProviders: protectedProcedure.query(async ({ ctx }) => {
giteaProviders: protectedProcedure
.meta({
openapi: {
summary: "List Gitea providers",
description: "Returns all Gitea providers accessible to the current user within the active organization, filtered to only those with valid credentials.",
},
})
.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
let result = await db.query.gitea.findMany({
@@ -88,6 +109,12 @@ export const giteaRouter = createTRPCRouter({
}),
getGiteaRepositories: protectedProcedure
.meta({
openapi: {
summary: "List Gitea repositories",
description: "Fetches the list of repositories accessible by the Gitea provider. Calls the Gitea API using the provider's credentials.",
},
})
.input(apiFindOneGitea)
.query(async ({ input }) => {
const { giteaId } = input;
@@ -112,6 +139,12 @@ export const giteaRouter = createTRPCRouter({
}),
getGiteaBranches: protectedProcedure
.meta({
openapi: {
summary: "List Gitea branches",
description: "Fetches the list of branches for a specific Gitea repository. Calls the Gitea API using the provider's credentials.",
},
})
.input(apiFindGiteaBranches)
.query(async ({ input }) => {
const { giteaId, owner, repositoryName } = input;
@@ -140,6 +173,12 @@ export const giteaRouter = createTRPCRouter({
}),
testConnection: protectedProcedure
.meta({
openapi: {
summary: "Test Gitea connection",
description: "Tests the connection to a Gitea provider by attempting to fetch its repositories. Returns the number of repositories found or throws an error on failure.",
},
})
.input(apiGiteaTestConnection)
.mutation(async ({ input }) => {
const giteaId = input.giteaId ?? "";
@@ -160,6 +199,12 @@ export const giteaRouter = createTRPCRouter({
}),
update: withPermission("gitProviders", "create")
.meta({
openapi: {
summary: "Update Gitea provider",
description: "Updates a Gitea provider configuration and its associated git provider record. Requires gitProviders create permission.",
},
})
.input(apiUpdateGitea)
.mutation(async ({ input, ctx }) => {
if (input.name) {
@@ -188,6 +233,12 @@ export const giteaRouter = createTRPCRouter({
}),
getGiteaUrl: protectedProcedure
.meta({
openapi: {
summary: "Get Gitea instance URL",
description: "Returns the base URL of the Gitea instance associated with the given provider ID.",
},
})
.input(apiFindOneGitea)
.query(async ({ input }) => {
const { giteaId } = input;

View File

@@ -22,20 +22,47 @@ import {
} from "@/server/db/schema";
export const githubRouter = createTRPCRouter({
one: protectedProcedure.input(apiFindOneGithub).query(async ({ input }) => {
return await findGithubById(input.githubId);
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get GitHub provider",
description: "Returns a single GitHub provider configuration by its ID.",
},
})
.input(apiFindOneGithub)
.query(async ({ input }) => {
return await findGithubById(input.githubId);
}),
getGithubRepositories: protectedProcedure
.meta({
openapi: {
summary: "List GitHub repositories",
description: "Fetches the list of repositories accessible by the GitHub provider. Calls the GitHub API using the provider's credentials.",
},
})
.input(apiFindOneGithub)
.query(async ({ input }) => {
return await getGithubRepositories(input.githubId);
}),
getGithubBranches: protectedProcedure
.meta({
openapi: {
summary: "List GitHub branches",
description: "Fetches the list of branches for a specific GitHub repository. Calls the GitHub API using the provider's credentials.",
},
})
.input(apiFindGithubBranches)
.query(async ({ input }) => {
return await getGithubBranches(input);
}),
githubProviders: protectedProcedure.query(async ({ ctx }) => {
githubProviders: protectedProcedure
.meta({
openapi: {
summary: "List GitHub providers",
description: "Returns all GitHub providers accessible to the current user within the active organization, filtered to only those with valid credentials.",
},
})
.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
let result = await db.query.github.findMany({
@@ -66,6 +93,12 @@ export const githubRouter = createTRPCRouter({
}),
testConnection: protectedProcedure
.meta({
openapi: {
summary: "Test GitHub connection",
description: "Tests the connection to a GitHub provider by attempting to fetch its repositories. Returns the number of repositories found or throws an error on failure.",
},
})
.input(apiFindOneGithub)
.mutation(async ({ input }) => {
try {
@@ -79,6 +112,12 @@ export const githubRouter = createTRPCRouter({
}
}),
update: withPermission("gitProviders", "create")
.meta({
openapi: {
summary: "Update GitHub provider",
description: "Updates a GitHub provider configuration and its associated git provider record. Requires gitProviders create permission.",
},
})
.input(apiUpdateGithub)
.mutation(async ({ input, ctx }) => {
await updateGitProvider(input.gitProviderId, {

View File

@@ -27,6 +27,12 @@ import {
export const gitlabRouter = createTRPCRouter({
create: withPermission("gitProviders", "create")
.meta({
openapi: {
summary: "Create GitLab provider",
description: "Creates a new GitLab provider configuration linked to the active organization. Requires gitProviders create permission.",
},
})
.input(apiCreateGitlab)
.mutation(async ({ input, ctx }) => {
try {
@@ -51,10 +57,25 @@ export const gitlabRouter = createTRPCRouter({
});
}
}),
one: protectedProcedure.input(apiFindOneGitlab).query(async ({ input }) => {
return await findGitlabById(input.gitlabId);
}),
gitlabProviders: protectedProcedure.query(async ({ ctx }) => {
one: protectedProcedure
.meta({
openapi: {
summary: "Get GitLab provider",
description: "Returns a single GitLab provider configuration by its ID.",
},
})
.input(apiFindOneGitlab)
.query(async ({ input }) => {
return await findGitlabById(input.gitlabId);
}),
gitlabProviders: protectedProcedure
.meta({
openapi: {
summary: "List GitLab providers",
description: "Returns all GitLab providers accessible to the current user within the active organization, filtered to only those with valid credentials.",
},
})
.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
let result = await db.query.gitlab.findMany({
@@ -85,17 +106,35 @@ export const gitlabRouter = createTRPCRouter({
return filtered;
}),
getGitlabRepositories: protectedProcedure
.meta({
openapi: {
summary: "List GitLab repositories",
description: "Fetches the list of repositories accessible by the GitLab provider. Calls the GitLab API using the provider's credentials.",
},
})
.input(apiFindOneGitlab)
.query(async ({ input }) => {
return await getGitlabRepositories(input.gitlabId);
}),
getGitlabBranches: protectedProcedure
.meta({
openapi: {
summary: "List GitLab branches",
description: "Fetches the list of branches for a specific GitLab repository. Calls the GitLab API using the provider's credentials.",
},
})
.input(apiFindGitlabBranches)
.query(async ({ input }) => {
return await getGitlabBranches(input);
}),
testConnection: protectedProcedure
.meta({
openapi: {
summary: "Test GitLab connection",
description: "Tests the connection to a GitLab provider by attempting to fetch its repositories. Returns the number of repositories found or throws an error on failure.",
},
})
.input(apiGitlabTestConnection)
.mutation(async ({ input }) => {
try {
@@ -110,6 +149,12 @@ export const gitlabRouter = createTRPCRouter({
}
}),
update: withPermission("gitProviders", "create")
.meta({
openapi: {
summary: "Update GitLab provider",
description: "Updates a GitLab provider configuration and its associated git provider record. Requires gitProviders create permission.",
},
})
.input(apiUpdateGitlab)
.mutation(async ({ input, ctx }) => {
if (input.name) {

View File

@@ -6,6 +6,8 @@ import {
findEnvironmentById,
findLibsqlById,
findProjectById,
getAccessibleServerIds,
getContainerLogs,
IS_CLOUD,
rebuildDatabase,
removeLibsqlById,
@@ -15,7 +17,6 @@ import {
stopService,
stopServiceRemote,
updateLibsqlById,
getAccessibleServerIds,
} from "@dokploy/server";
import {
addNewService,
@@ -42,6 +43,12 @@ import {
} from "@/server/db/schema";
export const libsqlRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a LibSQL database",
description: "Creates a new LibSQL database service with the specified configuration, sets up a persistent data volume, and registers it in the project.",
},
})
.input(apiCreateLibsql)
.mutation(async ({ input, ctx }) => {
try {
@@ -99,6 +106,12 @@ export const libsqlRouter = createTRPCRouter({
}
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a LibSQL database by ID",
description: "Returns the full details of a LibSQL database service, including its environment and project configuration.",
},
})
.input(apiFindOneLibsql)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.libsqlId, "read");
@@ -117,6 +130,12 @@ export const libsqlRouter = createTRPCRouter({
}),
start: protectedProcedure
.meta({
openapi: {
summary: "Start a LibSQL database",
description: "Starts the Docker container for the specified LibSQL database and sets its status to done.",
},
})
.input(apiFindOneLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
@@ -142,6 +161,12 @@ export const libsqlRouter = createTRPCRouter({
return libsql;
}),
stop: protectedProcedure
.meta({
openapi: {
summary: "Stop a LibSQL database",
description: "Stops the Docker container for the specified LibSQL database and sets its status to idle.",
},
})
.input(apiFindOneLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
@@ -167,6 +192,12 @@ export const libsqlRouter = createTRPCRouter({
return libsql;
}),
saveExternalPorts: protectedProcedure
.meta({
openapi: {
summary: "Save the external ports for a LibSQL database",
description: "Updates the external port mappings (HTTP, gRPC, admin) for the LibSQL database and triggers a redeployment. Validates that ports are not already in use.",
},
})
.input(apiSaveExternalPortsLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
@@ -229,6 +260,12 @@ export const libsqlRouter = createTRPCRouter({
return libsql;
}),
deploy: protectedProcedure
.meta({
openapi: {
summary: "Deploy a LibSQL database",
description: "Triggers a deployment for the specified LibSQL database, rebuilding and restarting its Docker container with the current configuration.",
},
})
.input(apiDeployLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
@@ -281,6 +318,12 @@ export const libsqlRouter = createTRPCRouter({
}
}),
changeStatus: protectedProcedure
.meta({
openapi: {
summary: "Change LibSQL database status",
description: "Updates the application status of a LibSQL database without starting or stopping the container.",
},
})
.input(apiChangeLibsqlStatus)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
@@ -299,6 +342,12 @@ export const libsqlRouter = createTRPCRouter({
return libsql;
}),
remove: protectedProcedure
.meta({
openapi: {
summary: "Delete a LibSQL database",
description: "Removes the LibSQL database service, its Docker container, and deletes the database record.",
},
})
.input(apiFindOneLibsql)
.mutation(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.libsqlId, "delete");
@@ -334,6 +383,12 @@ export const libsqlRouter = createTRPCRouter({
return libsql;
}),
saveEnvironment: protectedProcedure
.meta({
openapi: {
summary: "Save environment variables for a LibSQL database",
description: "Updates the environment variables for the specified LibSQL database service.",
},
})
.input(apiSaveEnvironmentVariablesLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
@@ -358,6 +413,12 @@ export const libsqlRouter = createTRPCRouter({
return true;
}),
reload: protectedProcedure
.meta({
openapi: {
summary: "Reload a LibSQL database",
description: "Restarts the LibSQL database by stopping and then starting its Docker container.",
},
})
.input(apiResetLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
@@ -390,6 +451,12 @@ export const libsqlRouter = createTRPCRouter({
return true;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a LibSQL database",
description: "Updates the configuration of an existing LibSQL database service.",
},
})
.input(apiUpdateLibsql)
.mutation(async ({ input, ctx }) => {
const { libsqlId, ...rest } = input;
@@ -416,6 +483,12 @@ export const libsqlRouter = createTRPCRouter({
return true;
}),
move: protectedProcedure
.meta({
openapi: {
summary: "Move a LibSQL database to another environment",
description: "Moves the LibSQL database to a different environment within the same project.",
},
})
.input(
z.object({
libsqlId: z.string(),
@@ -452,6 +525,12 @@ export const libsqlRouter = createTRPCRouter({
return updatedLibsql;
}),
rebuild: protectedProcedure
.meta({
openapi: {
summary: "Rebuild a LibSQL database",
description: "Rebuilds the LibSQL database Docker container from scratch, pulling the latest image and recreating the service.",
},
})
.input(apiRebuildLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
@@ -466,4 +545,45 @@ export const libsqlRouter = createTRPCRouter({
});
return true;
}),
readLogs: protectedProcedure
.meta({
openapi: {
summary: "Read LibSQL container logs",
description: "Retrieves the Docker container logs for the specified LibSQL database, with support for tail count, time-based filtering, and text search.",
},
})
.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 {
@@ -53,6 +54,12 @@ import {
import { cancelJobs } from "@/server/utils/backup";
export const mariadbRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a MariaDB database",
description: "Creates a new MariaDB database service with the specified configuration, sets up a persistent data volume, and registers it in the project.",
},
})
.input(apiCreateMariaDB)
.mutation(async ({ input, ctx }) => {
try {
@@ -113,6 +120,12 @@ export const mariadbRouter = createTRPCRouter({
}
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a MariaDB database by ID",
description: "Returns the full details of a MariaDB database service, including its environment and project configuration.",
},
})
.input(apiFindOneMariaDB)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.mariadbId, "read");
@@ -130,6 +143,12 @@ export const mariadbRouter = createTRPCRouter({
}),
start: protectedProcedure
.meta({
openapi: {
summary: "Start a MariaDB database",
description: "Starts the Docker container for the specified MariaDB database and sets its status to done.",
},
})
.input(apiFindOneMariaDB)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
@@ -154,6 +173,12 @@ export const mariadbRouter = createTRPCRouter({
return service;
}),
stop: protectedProcedure
.meta({
openapi: {
summary: "Stop a MariaDB database",
description: "Stops the Docker container for the specified MariaDB database and sets its status to idle.",
},
})
.input(apiFindOneMariaDB)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
@@ -179,6 +204,12 @@ export const mariadbRouter = createTRPCRouter({
return mariadb;
}),
saveExternalPort: protectedProcedure
.meta({
openapi: {
summary: "Save the external port for a MariaDB database",
description: "Updates the external port mapping for the MariaDB database and triggers a redeployment. Validates that the port is not already in use.",
},
})
.input(apiSaveExternalPortMariaDB)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
@@ -212,6 +243,12 @@ export const mariadbRouter = createTRPCRouter({
return mariadb;
}),
deploy: protectedProcedure
.meta({
openapi: {
summary: "Deploy a MariaDB database",
description: "Triggers a deployment for the specified MariaDB database, rebuilding and restarting its Docker container with the current configuration.",
},
})
.input(apiDeployMariaDB)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
@@ -249,6 +286,12 @@ export const mariadbRouter = createTRPCRouter({
});
}),
changeStatus: protectedProcedure
.meta({
openapi: {
summary: "Change MariaDB database status",
description: "Updates the application status of a MariaDB database without starting or stopping the container.",
},
})
.input(apiChangeMariaDBStatus)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
@@ -267,6 +310,12 @@ export const mariadbRouter = createTRPCRouter({
return mongo;
}),
remove: protectedProcedure
.meta({
openapi: {
summary: "Delete a MariaDB database",
description: "Removes the MariaDB database service, its Docker container, cancels associated backup jobs, and deletes the database record.",
},
})
.input(apiFindOneMariaDB)
.mutation(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.mariadbId, "delete");
@@ -304,6 +353,12 @@ export const mariadbRouter = createTRPCRouter({
return mongo;
}),
saveEnvironment: protectedProcedure
.meta({
openapi: {
summary: "Save environment variables for a MariaDB database",
description: "Updates the environment variables for the specified MariaDB database service.",
},
})
.input(apiSaveEnvironmentVariablesMariaDB)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
@@ -328,6 +383,12 @@ export const mariadbRouter = createTRPCRouter({
return true;
}),
reload: protectedProcedure
.meta({
openapi: {
summary: "Reload a MariaDB database",
description: "Restarts the MariaDB database by stopping and then starting its Docker container.",
},
})
.input(apiResetMariadb)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
@@ -360,6 +421,12 @@ export const mariadbRouter = createTRPCRouter({
return true;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a MariaDB database",
description: "Updates the configuration of an existing MariaDB database service.",
},
})
.input(apiUpdateMariaDB)
.mutation(async ({ input, ctx }) => {
const { mariadbId, ...rest } = input;
@@ -386,6 +453,12 @@ export const mariadbRouter = createTRPCRouter({
return true;
}),
changePassword: protectedProcedure
.meta({
openapi: {
summary: "Change MariaDB database password",
description: "Changes the password for a MariaDB user or root account by executing ALTER USER inside the running container and updating the stored password.",
},
})
.input(
z.object({
mariadbId: z.string().min(1),
@@ -443,6 +516,12 @@ export const mariadbRouter = createTRPCRouter({
return true;
}),
move: protectedProcedure
.meta({
openapi: {
summary: "Move a MariaDB database to another environment",
description: "Moves the MariaDB database to a different environment within the same project.",
},
})
.input(
z.object({
mariadbId: z.string(),
@@ -479,6 +558,12 @@ export const mariadbRouter = createTRPCRouter({
return updatedMariadb;
}),
rebuild: protectedProcedure
.meta({
openapi: {
summary: "Rebuild a MariaDB database",
description: "Rebuilds the MariaDB database Docker container from scratch, pulling the latest image and recreating the service.",
},
})
.input(apiRebuildMariadb)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
@@ -494,6 +579,12 @@ export const mariadbRouter = createTRPCRouter({
return true;
}),
search: protectedProcedure
.meta({
openapi: {
summary: "Search MariaDB databases",
description: "Returns a paginated list of MariaDB databases matching the given filters. Supports searching by name, appName, description, project, and environment.",
},
})
.input(
z.object({
q: z.string().optional(),
@@ -590,4 +681,45 @@ export const mariadbRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.meta({
openapi: {
summary: "Read MariaDB container logs",
description: "Retrieves the Docker container logs for the specified MariaDB database, with support for tail count, time-based filtering, and text search.",
},
})
.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,
@@ -52,6 +53,12 @@ import {
import { cancelJobs } from "@/server/utils/backup";
export const mongoRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a MongoDB database",
description: "Creates a new MongoDB database service with the specified configuration, sets up a persistent data volume, and registers it in the project.",
},
})
.input(apiCreateMongo)
.mutation(async ({ input, ctx }) => {
try {
@@ -116,6 +123,12 @@ export const mongoRouter = createTRPCRouter({
}
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a MongoDB database by ID",
description: "Returns the full details of a MongoDB database service, including its environment and project configuration.",
},
})
.input(apiFindOneMongo)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.mongoId, "read");
@@ -134,6 +147,12 @@ export const mongoRouter = createTRPCRouter({
}),
start: protectedProcedure
.meta({
openapi: {
summary: "Start a MongoDB database",
description: "Starts the Docker container for the specified MongoDB database and sets its status to done.",
},
})
.input(apiFindOneMongo)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
@@ -159,6 +178,12 @@ export const mongoRouter = createTRPCRouter({
return service;
}),
stop: protectedProcedure
.meta({
openapi: {
summary: "Stop a MongoDB database",
description: "Stops the Docker container for the specified MongoDB database and sets its status to idle.",
},
})
.input(apiFindOneMongo)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
@@ -184,6 +209,12 @@ export const mongoRouter = createTRPCRouter({
return mongo;
}),
saveExternalPort: protectedProcedure
.meta({
openapi: {
summary: "Save the external port for a MongoDB database",
description: "Updates the external port mapping for the MongoDB database and triggers a redeployment. Validates that the port is not already in use.",
},
})
.input(apiSaveExternalPortMongo)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
@@ -217,6 +248,12 @@ export const mongoRouter = createTRPCRouter({
return mongo;
}),
deploy: protectedProcedure
.meta({
openapi: {
summary: "Deploy a MongoDB database",
description: "Triggers a deployment for the specified MongoDB database, rebuilding and restarting its Docker container with the current configuration.",
},
})
.input(apiDeployMongo)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
@@ -270,6 +307,12 @@ export const mongoRouter = createTRPCRouter({
}),
changeStatus: protectedProcedure
.meta({
openapi: {
summary: "Change MongoDB database status",
description: "Updates the application status of a MongoDB database without starting or stopping the container.",
},
})
.input(apiChangeMongoStatus)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
@@ -288,6 +331,12 @@ export const mongoRouter = createTRPCRouter({
return mongo;
}),
reload: protectedProcedure
.meta({
openapi: {
summary: "Reload a MongoDB database",
description: "Restarts the MongoDB database by stopping and then starting its Docker container.",
},
})
.input(apiResetMongo)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
@@ -320,6 +369,12 @@ export const mongoRouter = createTRPCRouter({
return true;
}),
remove: protectedProcedure
.meta({
openapi: {
summary: "Delete a MongoDB database",
description: "Removes the MongoDB database service, its Docker container, cancels associated backup jobs, and deletes the database record.",
},
})
.input(apiFindOneMongo)
.mutation(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.mongoId, "delete");
@@ -358,6 +413,12 @@ export const mongoRouter = createTRPCRouter({
return mongo;
}),
saveEnvironment: protectedProcedure
.meta({
openapi: {
summary: "Save environment variables for a MongoDB database",
description: "Updates the environment variables for the specified MongoDB database service.",
},
})
.input(apiSaveEnvironmentVariablesMongo)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
@@ -382,6 +443,12 @@ export const mongoRouter = createTRPCRouter({
return true;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a MongoDB database",
description: "Updates the configuration of an existing MongoDB database service.",
},
})
.input(apiUpdateMongo)
.mutation(async ({ input, ctx }) => {
const { mongoId, ...rest } = input;
@@ -408,6 +475,12 @@ export const mongoRouter = createTRPCRouter({
return true;
}),
changePassword: protectedProcedure
.meta({
openapi: {
summary: "Change MongoDB database password",
description: "Changes the password for the MongoDB database user by executing changeUserPassword via mongosh inside the running container and updating the stored password.",
},
})
.input(
z.object({
mongoId: z.string().min(1),
@@ -458,6 +531,12 @@ export const mongoRouter = createTRPCRouter({
return true;
}),
move: protectedProcedure
.meta({
openapi: {
summary: "Move a MongoDB database to another environment",
description: "Moves the MongoDB database to a different environment within the same project.",
},
})
.input(
z.object({
mongoId: z.string(),
@@ -494,6 +573,12 @@ export const mongoRouter = createTRPCRouter({
return updatedMongo;
}),
rebuild: protectedProcedure
.meta({
openapi: {
summary: "Rebuild a MongoDB database",
description: "Rebuilds the MongoDB database Docker container from scratch, pulling the latest image and recreating the service.",
},
})
.input(apiRebuildMongo)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
@@ -510,6 +595,12 @@ export const mongoRouter = createTRPCRouter({
return true;
}),
search: protectedProcedure
.meta({
openapi: {
summary: "Search MongoDB databases",
description: "Returns a paginated list of MongoDB databases matching the given filters. Supports searching by name, appName, description, project, and environment.",
},
})
.input(
z.object({
q: z.string().optional(),
@@ -601,4 +692,45 @@ export const mongoRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.meta({
openapi: {
summary: "Read MongoDB container logs",
description: "Retrieves the Docker container logs for the specified MongoDB database, with support for tail count, time-based filtering, and text search.",
},
})
.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

@@ -75,6 +75,12 @@ async function getServiceOrganizationId(
export const mountRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create mount",
description: "Creates a new volume, bind, or file mount for a service. Checks service-level volume permissions and logs an audit event.",
},
})
.input(apiCreateMount)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.serviceId, {
@@ -90,6 +96,12 @@ export const mountRouter = createTRPCRouter({
return mount;
}),
remove: protectedProcedure
.meta({
openapi: {
summary: "Delete mount",
description: "Removes a mount by ID. Resolves the owning service to check volume delete permissions and logs an audit event.",
},
})
.input(apiRemoveMount)
.mutation(async ({ input, ctx }) => {
const mount = await findMountById(input.mountId);
@@ -116,6 +128,12 @@ export const mountRouter = createTRPCRouter({
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get mount",
description: "Returns a single mount by ID. Resolves the owning service to check volume read permissions.",
},
})
.input(apiFindOneMount)
.query(async ({ input, ctx }) => {
const mount = await findMountById(input.mountId);
@@ -136,6 +154,12 @@ export const mountRouter = createTRPCRouter({
return mount;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update mount",
description: "Updates an existing mount. Resolves the owning service to check volume create permissions and logs an audit event.",
},
})
.input(apiUpdateMount)
.mutation(async ({ input, ctx }) => {
const mount = await findMountById(input.mountId);
@@ -162,6 +186,12 @@ export const mountRouter = createTRPCRouter({
return await updateMount(input.mountId, input);
}),
allNamedByApplicationId: protectedProcedure
.meta({
openapi: {
summary: "List named volumes by application",
description: "Returns Docker named volumes attached to the running container of a given application. Inspects the live container to retrieve mount information.",
},
})
.input(z.object({ applicationId: z.string().min(1) }))
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -175,6 +205,12 @@ export const mountRouter = createTRPCRouter({
return mounts;
}),
listByServiceId: protectedProcedure
.meta({
openapi: {
summary: "List mounts by service",
description: "Returns all configured mounts for a given service (application, compose, or database). Verifies service access and organization ownership.",
},
})
.input(apiFindMountByApplicationId)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.serviceId, "read");

View File

@@ -9,6 +9,7 @@ import {
findEnvironmentById,
findMySqlById,
findProjectById,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -53,6 +54,12 @@ import { cancelJobs } from "@/server/utils/backup";
export const mysqlRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a MySQL database",
description: "Creates a new MySQL database service with the specified configuration, sets up a persistent data volume, and registers it in the project.",
},
})
.input(apiCreateMySql)
.mutation(async ({ input, ctx }) => {
try {
@@ -117,6 +124,12 @@ export const mysqlRouter = createTRPCRouter({
}
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a MySQL database by ID",
description: "Returns the full details of a MySQL database service, including its environment and project configuration.",
},
})
.input(apiFindOneMySql)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.mysqlId, "read");
@@ -134,6 +147,12 @@ export const mysqlRouter = createTRPCRouter({
}),
start: protectedProcedure
.meta({
openapi: {
summary: "Start a MySQL database",
description: "Starts the Docker container for the specified MySQL database and sets its status to done.",
},
})
.input(apiFindOneMySql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
@@ -159,6 +178,12 @@ export const mysqlRouter = createTRPCRouter({
return service;
}),
stop: protectedProcedure
.meta({
openapi: {
summary: "Stop a MySQL database",
description: "Stops the Docker container for the specified MySQL database and sets its status to idle.",
},
})
.input(apiFindOneMySql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
@@ -183,6 +208,12 @@ export const mysqlRouter = createTRPCRouter({
return mongo;
}),
saveExternalPort: protectedProcedure
.meta({
openapi: {
summary: "Save the external port for a MySQL database",
description: "Updates the external port mapping for the MySQL database and triggers a redeployment. Validates that the port is not already in use.",
},
})
.input(apiSaveExternalPortMySql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
@@ -216,6 +247,12 @@ export const mysqlRouter = createTRPCRouter({
return mysql;
}),
deploy: protectedProcedure
.meta({
openapi: {
summary: "Deploy a MySQL database",
description: "Triggers a deployment for the specified MySQL database, rebuilding and restarting its Docker container with the current configuration.",
},
})
.input(apiDeployMySql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
@@ -269,6 +306,12 @@ export const mysqlRouter = createTRPCRouter({
}
}),
changeStatus: protectedProcedure
.meta({
openapi: {
summary: "Change MySQL database status",
description: "Updates the application status of a MySQL database without starting or stopping the container.",
},
})
.input(apiChangeMySqlStatus)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
@@ -287,6 +330,12 @@ export const mysqlRouter = createTRPCRouter({
return mongo;
}),
reload: protectedProcedure
.meta({
openapi: {
summary: "Reload a MySQL database",
description: "Restarts the MySQL database by stopping and then starting its Docker container.",
},
})
.input(apiResetMysql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
@@ -318,6 +367,12 @@ export const mysqlRouter = createTRPCRouter({
return true;
}),
remove: protectedProcedure
.meta({
openapi: {
summary: "Delete a MySQL database",
description: "Removes the MySQL database service, its Docker container, cancels associated backup jobs, and deletes the database record.",
},
})
.input(apiFindOneMySql)
.mutation(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.mysqlId, "delete");
@@ -354,6 +409,12 @@ export const mysqlRouter = createTRPCRouter({
return mongo;
}),
saveEnvironment: protectedProcedure
.meta({
openapi: {
summary: "Save environment variables for a MySQL database",
description: "Updates the environment variables for the specified MySQL database service.",
},
})
.input(apiSaveEnvironmentVariablesMySql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
@@ -378,6 +439,12 @@ export const mysqlRouter = createTRPCRouter({
return true;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a MySQL database",
description: "Updates the configuration of an existing MySQL database service.",
},
})
.input(apiUpdateMySql)
.mutation(async ({ input, ctx }) => {
const { mysqlId, ...rest } = input;
@@ -404,6 +471,12 @@ export const mysqlRouter = createTRPCRouter({
return true;
}),
changePassword: protectedProcedure
.meta({
openapi: {
summary: "Change MySQL database password",
description: "Changes the password for a MySQL user or root account by executing ALTER USER inside the running container and updating the stored password.",
},
})
.input(
z.object({
mysqlId: z.string().min(1),
@@ -461,6 +534,12 @@ export const mysqlRouter = createTRPCRouter({
return true;
}),
move: protectedProcedure
.meta({
openapi: {
summary: "Move a MySQL database to another environment",
description: "Moves the MySQL database to a different environment within the same project.",
},
})
.input(
z.object({
mysqlId: z.string(),
@@ -497,6 +576,12 @@ export const mysqlRouter = createTRPCRouter({
return updatedMysql;
}),
rebuild: protectedProcedure
.meta({
openapi: {
summary: "Rebuild a MySQL database",
description: "Rebuilds the MySQL database Docker container from scratch, pulling the latest image and recreating the service.",
},
})
.input(apiRebuildMysql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
@@ -513,6 +598,12 @@ export const mysqlRouter = createTRPCRouter({
return true;
}),
search: protectedProcedure
.meta({
openapi: {
summary: "Search MySQL databases",
description: "Returns a paginated list of MySQL databases matching the given filters. Supports searching by name, appName, description, project, and environment.",
},
})
.input(
z.object({
q: z.string().optional(),
@@ -604,4 +695,45 @@ export const mysqlRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.meta({
openapi: {
summary: "Read MySQL container logs",
description: "Retrieves the Docker container logs for the specified MySQL database, with support for tail count, time-based filtering, and text search.",
},
})
.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

@@ -95,6 +95,12 @@ import {
export const notificationRouter = createTRPCRouter({
createSlack: withPermission("notification", "create")
.meta({
openapi: {
summary: "Create Slack notification",
description: "Creates a new Slack notification provider for the current organization and logs an audit event.",
},
})
.input(apiCreateSlack)
.mutation(async ({ input, ctx }) => {
try {
@@ -114,6 +120,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
updateSlack: withPermission("notification", "update")
.meta({
openapi: {
summary: "Update Slack notification",
description: "Updates an existing Slack notification provider. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdateSlack)
.mutation(async ({ input, ctx }) => {
try {
@@ -140,6 +152,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
testSlackConnection: withPermission("notification", "create")
.meta({
openapi: {
summary: "Test Slack connection",
description: "Sends a test message to the configured Slack channel to verify the webhook connection works.",
},
})
.input(apiTestSlackConnection)
.mutation(async ({ input }) => {
try {
@@ -157,6 +175,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
createTelegram: withPermission("notification", "create")
.meta({
openapi: {
summary: "Create Telegram notification",
description: "Creates a new Telegram notification provider for the current organization and logs an audit event.",
},
})
.input(apiCreateTelegram)
.mutation(async ({ input, ctx }) => {
try {
@@ -179,6 +203,12 @@ export const notificationRouter = createTRPCRouter({
}),
updateTelegram: withPermission("notification", "update")
.meta({
openapi: {
summary: "Update Telegram notification",
description: "Updates an existing Telegram notification provider. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdateTelegram)
.mutation(async ({ input, ctx }) => {
try {
@@ -209,6 +239,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
testTelegramConnection: withPermission("notification", "create")
.meta({
openapi: {
summary: "Test Telegram connection",
description: "Sends a test message to the configured Telegram chat to verify the bot token and chat ID work.",
},
})
.input(apiTestTelegramConnection)
.mutation(async ({ input }) => {
try {
@@ -223,6 +259,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
createDiscord: withPermission("notification", "create")
.meta({
openapi: {
summary: "Create Discord notification",
description: "Creates a new Discord notification provider for the current organization and logs an audit event.",
},
})
.input(apiCreateDiscord)
.mutation(async ({ input, ctx }) => {
try {
@@ -245,6 +287,12 @@ export const notificationRouter = createTRPCRouter({
}),
updateDiscord: withPermission("notification", "update")
.meta({
openapi: {
summary: "Update Discord notification",
description: "Updates an existing Discord notification provider. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdateDiscord)
.mutation(async ({ input, ctx }) => {
try {
@@ -276,6 +324,12 @@ export const notificationRouter = createTRPCRouter({
}),
testDiscordConnection: withPermission("notification", "create")
.meta({
openapi: {
summary: "Test Discord connection",
description: "Sends a test embed message to the configured Discord webhook to verify the connection works.",
},
})
.input(apiTestDiscordConnection)
.mutation(async ({ input }) => {
try {
@@ -298,6 +352,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
createEmail: withPermission("notification", "create")
.meta({
openapi: {
summary: "Create Email notification",
description: "Creates a new SMTP email notification provider for the current organization and logs an audit event.",
},
})
.input(apiCreateEmail)
.mutation(async ({ input, ctx }) => {
try {
@@ -316,6 +376,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
updateEmail: withPermission("notification", "update")
.meta({
openapi: {
summary: "Update Email notification",
description: "Updates an existing SMTP email notification provider. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdateEmail)
.mutation(async ({ input, ctx }) => {
try {
@@ -346,6 +412,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
testEmailConnection: withPermission("notification", "create")
.meta({
openapi: {
summary: "Test Email connection",
description: "Sends a test email via the configured SMTP settings to verify the connection works.",
},
})
.input(apiTestEmailConnection)
.mutation(async ({ input }) => {
try {
@@ -364,6 +436,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
createResend: withPermission("notification", "create")
.meta({
openapi: {
summary: "Create Resend notification",
description: "Creates a new Resend email notification provider for the current organization and logs an audit event.",
},
})
.input(apiCreateResend)
.mutation(async ({ input, ctx }) => {
try {
@@ -382,6 +460,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
updateResend: withPermission("notification", "update")
.meta({
openapi: {
summary: "Update Resend notification",
description: "Updates an existing Resend email notification provider. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdateResend)
.mutation(async ({ input, ctx }) => {
try {
@@ -412,6 +496,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
testResendConnection: withPermission("notification", "create")
.meta({
openapi: {
summary: "Test Resend connection",
description: "Sends a test email via Resend to verify the API key and configuration work.",
},
})
.input(apiTestResendConnection)
.mutation(async ({ input }) => {
try {
@@ -430,6 +520,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
remove: withPermission("notification", "delete")
.meta({
openapi: {
summary: "Delete notification",
description: "Removes a notification provider by ID. Verifies organization ownership and logs an audit event before deletion.",
},
})
.input(apiFindOneNotification)
.mutation(async ({ input, ctx }) => {
try {
@@ -458,6 +554,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
one: withPermission("notification", "read")
.meta({
openapi: {
summary: "Get notification",
description: "Returns a single notification provider by ID. Verifies the caller belongs to the same organization.",
},
})
.input(apiFindOneNotification)
.query(async ({ input, ctx }) => {
const notification = await findNotificationById(input.notificationId);
@@ -469,7 +571,14 @@ export const notificationRouter = createTRPCRouter({
}
return notification;
}),
all: withPermission("notification", "read").query(async ({ ctx }) => {
all: withPermission("notification", "read")
.meta({
openapi: {
summary: "List all notifications",
description: "Returns all notification providers for the current organization, including all provider-specific details (Slack, Telegram, Discord, etc.).",
},
})
.query(async ({ ctx }) => {
return await db.query.notifications.findMany({
with: {
slack: true,
@@ -490,6 +599,12 @@ export const notificationRouter = createTRPCRouter({
});
}),
receiveNotification: publicProcedure
.meta({
openapi: {
summary: "Receive server threshold notification",
description: "Public endpoint that receives CPU/memory threshold alerts from Dokploy or remote servers. Validates the token and dispatches notifications to all configured providers.",
},
})
.input(
z.object({
ServerType: z.enum(["Dokploy", "Remote"]).default("Dokploy"),
@@ -551,6 +666,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
createGotify: withPermission("notification", "create")
.meta({
openapi: {
summary: "Create Gotify notification",
description: "Creates a new Gotify notification provider for the current organization and logs an audit event.",
},
})
.input(apiCreateGotify)
.mutation(async ({ input, ctx }) => {
try {
@@ -569,6 +690,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
updateGotify: withPermission("notification", "update")
.meta({
openapi: {
summary: "Update Gotify notification",
description: "Updates an existing Gotify notification provider. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdateGotify)
.mutation(async ({ input, ctx }) => {
try {
@@ -598,6 +725,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
testGotifyConnection: withPermission("notification", "create")
.meta({
openapi: {
summary: "Test Gotify connection",
description: "Sends a test notification to the configured Gotify server to verify the connection works.",
},
})
.input(apiTestGotifyConnection)
.mutation(async ({ input }) => {
try {
@@ -616,6 +749,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
createNtfy: withPermission("notification", "create")
.meta({
openapi: {
summary: "Create ntfy notification",
description: "Creates a new ntfy notification provider for the current organization and logs an audit event.",
},
})
.input(apiCreateNtfy)
.mutation(async ({ input, ctx }) => {
try {
@@ -634,6 +773,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
updateNtfy: withPermission("notification", "update")
.meta({
openapi: {
summary: "Update ntfy notification",
description: "Updates an existing ntfy notification provider. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdateNtfy)
.mutation(async ({ input, ctx }) => {
try {
@@ -663,6 +808,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
testNtfyConnection: withPermission("notification", "create")
.meta({
openapi: {
summary: "Test ntfy connection",
description: "Sends a test notification to the configured ntfy topic to verify the connection works.",
},
})
.input(apiTestNtfyConnection)
.mutation(async ({ input }) => {
try {
@@ -677,12 +828,21 @@ export const notificationRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
message:
error instanceof Error
? `Error testing the notification: ${error.message}`
: "Error testing the notification",
cause: error,
});
}
}),
createMattermost: withPermission("notification", "create")
.meta({
openapi: {
summary: "Create Mattermost notification",
description: "Creates a new Mattermost notification provider for the current organization and logs an audit event.",
},
})
.input(apiCreateMattermost)
.mutation(async ({ input, ctx }) => {
try {
@@ -704,6 +864,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
updateMattermost: withPermission("notification", "update")
.meta({
openapi: {
summary: "Update Mattermost notification",
description: "Updates an existing Mattermost notification provider. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdateMattermost)
.mutation(async ({ input, ctx }) => {
try {
@@ -733,6 +899,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
testMattermostConnection: withPermission("notification", "create")
.meta({
openapi: {
summary: "Test Mattermost connection",
description: "Sends a test message to the configured Mattermost webhook to verify the connection works.",
},
})
.input(apiTestMattermostConnection)
.mutation(async ({ input }) => {
try {
@@ -751,6 +923,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
createCustom: withPermission("notification", "create")
.meta({
openapi: {
summary: "Create custom webhook notification",
description: "Creates a new custom webhook notification provider for the current organization and logs an audit event.",
},
})
.input(apiCreateCustom)
.mutation(async ({ input, ctx }) => {
try {
@@ -769,6 +947,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
updateCustom: withPermission("notification", "update")
.meta({
openapi: {
summary: "Update custom webhook notification",
description: "Updates an existing custom webhook notification provider. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdateCustom)
.mutation(async ({ input, ctx }) => {
try {
@@ -795,6 +979,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
testCustomConnection: withPermission("notification", "create")
.meta({
openapi: {
summary: "Test custom webhook connection",
description: "Sends a test payload to the configured custom webhook URL to verify the connection works.",
},
})
.input(apiTestCustomConnection)
.mutation(async ({ input }) => {
try {
@@ -813,6 +1003,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
createLark: withPermission("notification", "create")
.meta({
openapi: {
summary: "Create Lark notification",
description: "Creates a new Lark notification provider for the current organization and logs an audit event.",
},
})
.input(apiCreateLark)
.mutation(async ({ input, ctx }) => {
try {
@@ -831,6 +1027,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
updateLark: withPermission("notification", "update")
.meta({
openapi: {
summary: "Update Lark notification",
description: "Updates an existing Lark notification provider. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdateLark)
.mutation(async ({ input, ctx }) => {
try {
@@ -860,6 +1062,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
testLarkConnection: withPermission("notification", "create")
.meta({
openapi: {
summary: "Test Lark connection",
description: "Sends a test message to the configured Lark webhook to verify the connection works.",
},
})
.input(apiTestLarkConnection)
.mutation(async ({ input }) => {
try {
@@ -879,6 +1087,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
createTeams: withPermission("notification", "create")
.meta({
openapi: {
summary: "Create Teams notification",
description: "Creates a new Microsoft Teams notification provider for the current organization and logs an audit event.",
},
})
.input(apiCreateTeams)
.mutation(async ({ input, ctx }) => {
try {
@@ -897,6 +1111,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
updateTeams: withPermission("notification", "update")
.meta({
openapi: {
summary: "Update Teams notification",
description: "Updates an existing Microsoft Teams notification provider. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdateTeams)
.mutation(async ({ input, ctx }) => {
try {
@@ -926,6 +1146,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
testTeamsConnection: withPermission("notification", "create")
.meta({
openapi: {
summary: "Test Teams connection",
description: "Sends a test message to the configured Microsoft Teams webhook to verify the connection works.",
},
})
.input(apiTestTeamsConnection)
.mutation(async ({ input }) => {
try {
@@ -943,6 +1169,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
createPushover: withPermission("notification", "create")
.meta({
openapi: {
summary: "Create Pushover notification",
description: "Creates a new Pushover notification provider for the current organization and logs an audit event.",
},
})
.input(apiCreatePushover)
.mutation(async ({ input, ctx }) => {
try {
@@ -964,6 +1196,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
updatePushover: withPermission("notification", "update")
.meta({
openapi: {
summary: "Update Pushover notification",
description: "Updates an existing Pushover notification provider. Verifies organization ownership before applying changes.",
},
})
.input(apiUpdatePushover)
.mutation(async ({ input, ctx }) => {
try {
@@ -993,6 +1231,12 @@ export const notificationRouter = createTRPCRouter({
}
}),
testPushoverConnection: withPermission("notification", "create")
.meta({
openapi: {
summary: "Test Pushover connection",
description: "Sends a test notification to the configured Pushover account to verify the connection works.",
},
})
.input(apiTestPushoverConnection)
.mutation(async ({ input }) => {
try {
@@ -1010,7 +1254,14 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
getEmailProviders: withPermission("notification", "read").query(
getEmailProviders: withPermission("notification", "read")
.meta({
openapi: {
summary: "List email notification providers",
description: "Returns all notification providers that support email (SMTP and Resend) for the current organization.",
},
})
.query(
async ({ ctx }) => {
return await db.query.notifications.findMany({
where: eq(

View File

@@ -15,6 +15,12 @@ import {
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const organizationRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create an organization",
description: "Create a new organization and add the current user as the owner. Only owners and admins can create organizations in self-hosted mode.",
},
})
.input(
z.object({
name: z.string(),
@@ -65,7 +71,14 @@ export const organizationRouter = createTRPCRouter({
});
return result;
}),
all: protectedProcedure.query(async ({ ctx }) => {
all: protectedProcedure
.meta({
openapi: {
summary: "List all organizations",
description: "Retrieve all organizations the current user is a member of, including their membership details.",
},
})
.query(async ({ ctx }) => {
const memberResult = await db.query.organization.findMany({
where: (organization) =>
exists(
@@ -88,6 +101,12 @@ export const organizationRouter = createTRPCRouter({
return memberResult;
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get an organization by ID",
description: "Retrieve a single organization by its ID. The current user must be a member of the organization.",
},
})
.input(
z.object({
organizationId: z.string(),
@@ -114,6 +133,12 @@ export const organizationRouter = createTRPCRouter({
});
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update an organization",
description: "Update the name and logo of an organization. Only the organization owner can perform this action.",
},
})
.input(
z.object({
organizationId: z.string(),
@@ -178,6 +203,12 @@ export const organizationRouter = createTRPCRouter({
return result[0];
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete an organization",
description: "Delete an organization by ID. Only the owner can delete it, and they must retain at least one organization.",
},
})
.input(
z.object({
organizationId: z.string(),
@@ -248,6 +279,12 @@ export const organizationRouter = createTRPCRouter({
return result;
}),
inviteMember: withPermission("member", "create")
.meta({
openapi: {
summary: "Invite a member to organization",
description: "Create a pending invitation for a user by email to join the active organization with the specified role. Checks for existing membership and pending invitations. Supports custom roles.",
},
})
.input(
z.object({
email: z.string().email(),
@@ -335,13 +372,26 @@ export const organizationRouter = createTRPCRouter({
return created;
}),
allInvitations: withPermission("member", "create").query(async ({ ctx }) => {
allInvitations: withPermission("member", "create")
.meta({
openapi: {
summary: "List all organization invitations",
description: "Retrieve all invitations for the active organization, ordered by status and expiration date.",
},
})
.query(async ({ ctx }) => {
return await db.query.invitation.findMany({
where: eq(invitation.organizationId, ctx.session.activeOrganizationId),
orderBy: [desc(invitation.status), desc(invitation.expiresAt)],
});
}),
removeInvitation: withPermission("member", "create")
.meta({
openapi: {
summary: "Remove an invitation",
description: "Delete a pending invitation by ID. Only invitations belonging to the active organization can be removed.",
},
})
.input(z.object({ invitationId: z.string() }))
.mutation(async ({ ctx, input }) => {
const invitationResult = await db.query.invitation.findFirst({
@@ -377,6 +427,12 @@ export const organizationRouter = createTRPCRouter({
return result;
}),
updateMemberRole: withPermission("member", "update")
.meta({
openapi: {
summary: "Update member role",
description: "Change the role of a member in the active organization. Users cannot change their own role, and the owner role is nontransferable. Only owners can change admin roles. Supports custom roles.",
},
})
.input(
z.object({
memberId: z.string(),
@@ -463,6 +519,12 @@ export const organizationRouter = createTRPCRouter({
return true;
}),
setDefault: protectedProcedure
.meta({
openapi: {
summary: "Set default organization",
description: "Set an organization as the default for the current user. Unsets any previous default and marks the specified organization as the new default.",
},
})
.input(
z.object({
organizationId: z.string().min(1),
@@ -509,7 +571,14 @@ export const organizationRouter = createTRPCRouter({
});
return { success: true };
}),
active: protectedProcedure.query(async ({ ctx }) => {
active: protectedProcedure
.meta({
openapi: {
summary: "Get active organization",
description: "Retrieve the organization that is currently active in the user's session. Returns null if no organization is active.",
},
})
.query(async ({ ctx }) => {
if (!ctx.session.activeOrganizationId) {
return null;
}

View File

@@ -50,6 +50,12 @@ const resolvePatchServiceId = (patch: {
export const patchRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create patch",
description: "Creates a new file patch for an application or compose service. Checks service-level permissions and logs an audit event.",
},
})
.input(apiCreatePatch)
.mutation(async ({ input, ctx }) => {
const serviceId = input.applicationId ?? input.composeId;
@@ -73,7 +79,15 @@ export const patchRouter = createTRPCRouter({
return result;
}),
one: protectedProcedure.input(apiFindPatch).query(async ({ input, ctx }) => {
one: protectedProcedure
.meta({
openapi: {
summary: "Get patch",
description: "Returns a single patch by ID. Resolves the associated service to verify read permissions.",
},
})
.input(apiFindPatch)
.query(async ({ input, ctx }) => {
const patch = await findPatchById(input.patchId);
const serviceId = resolvePatchServiceId(patch);
await checkServicePermissionAndAccess(ctx, serviceId, {
@@ -83,6 +97,12 @@ export const patchRouter = createTRPCRouter({
}),
byEntityId: protectedProcedure
.meta({
openapi: {
summary: "List patches by entity",
description: "Returns all patches associated with a given application or compose service.",
},
})
.input(
z.object({ id: z.string(), type: z.enum(["application", "compose"]) }),
)
@@ -94,6 +114,12 @@ export const patchRouter = createTRPCRouter({
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update patch",
description: "Updates the content or configuration of an existing patch. Resolves the associated service to verify permissions and logs an audit event.",
},
})
.input(apiUpdatePatch)
.mutation(async ({ input, ctx }) => {
const patch = await findPatchById(input.patchId);
@@ -114,6 +140,12 @@ export const patchRouter = createTRPCRouter({
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete patch",
description: "Deletes a patch by ID. Resolves the associated service to verify delete permissions and logs an audit event.",
},
})
.input(apiDeletePatch)
.mutation(async ({ input, ctx }) => {
const patch = await findPatchById(input.patchId);
@@ -133,6 +165,12 @@ export const patchRouter = createTRPCRouter({
}),
toggleEnabled: protectedProcedure
.meta({
openapi: {
summary: "Toggle patch enabled state",
description: "Enables or disables a patch without deleting it. Resolves the associated service to verify permissions and logs an audit event.",
},
})
.input(apiTogglePatchEnabled)
.mutation(async ({ input, ctx }) => {
const patch = await findPatchById(input.patchId);
@@ -155,6 +193,12 @@ export const patchRouter = createTRPCRouter({
// Repository Operations
ensureRepo: protectedProcedure
.meta({
openapi: {
summary: "Ensure patch repository exists",
description: "Ensures a patch repository is initialized for the given application or compose service. Creates the repo if it does not exist and logs an audit event.",
},
})
.input(
z.object({
id: z.string(),
@@ -179,6 +223,12 @@ export const patchRouter = createTRPCRouter({
}),
readRepoDirectories: protectedProcedure
.meta({
openapi: {
summary: "List patch repository directories",
description: "Reads the directory listing at a given path inside the patch repository for an application or compose service.",
},
})
.input(
z.object({
id: z.string().min(1),
@@ -202,6 +252,12 @@ export const patchRouter = createTRPCRouter({
}),
readRepoFile: protectedProcedure
.meta({
openapi: {
summary: "Read patch repository file",
description: "Reads a file from the patch repository. For delete-type patches it returns the current repo content; otherwise returns the patch content if available, falling back to the repo file.",
},
})
.input(
z.object({
id: z.string().min(1),
@@ -241,6 +297,12 @@ export const patchRouter = createTRPCRouter({
}),
saveFileAsPatch: protectedProcedure
.meta({
openapi: {
summary: "Save file as patch",
description: "Creates or updates a patch record from file content. If a patch already exists for the file path, it updates the existing patch; otherwise creates a new one.",
},
})
.input(
z.object({
id: z.string().min(1),
@@ -291,6 +353,12 @@ export const patchRouter = createTRPCRouter({
}),
markFileForDeletion: protectedProcedure
.meta({
openapi: {
summary: "Mark file for deletion",
description: "Creates a delete-type patch that will remove the specified file from the service on next deployment. Logs an audit event.",
},
})
.input(
z.object({
id: z.string().min(1),
@@ -318,6 +386,12 @@ export const patchRouter = createTRPCRouter({
}),
cleanPatchRepos: adminProcedure
.meta({
openapi: {
summary: "Clean patch repositories",
description: "Removes all patch repository working directories on the local or a specified remote server. Admin-only operation that logs an audit event.",
},
})
.input(z.object({ serverId: z.string().optional() }))
.mutation(async ({ input, ctx }) => {
await cleanPatchRepos(input.serverId);

View File

@@ -16,6 +16,12 @@ import {
export const portRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a port",
description: "Creates a new port mapping for an application, binding a published port to a target port. Logs an audit entry.",
},
})
.input(apiCreatePort)
.mutation(async ({ input, ctx }) => {
try {
@@ -39,6 +45,12 @@ export const portRouter = createTRPCRouter({
}
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a port",
description: "Returns a single port mapping by its ID, including the associated application details.",
},
})
.input(apiFindOnePort)
.query(async ({ input, ctx }) => {
try {
@@ -58,6 +70,12 @@ export const portRouter = createTRPCRouter({
}
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a port",
description: "Deletes a port mapping by its ID and logs an audit entry with the published and target port details.",
},
})
.input(apiFindOnePort)
.mutation(async ({ input, ctx }) => {
const port = await finPortById(input.portId);
@@ -85,6 +103,12 @@ export const portRouter = createTRPCRouter({
}
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a port",
description: "Updates an existing port mapping's configuration and logs an audit entry.",
},
})
.input(apiUpdatePort)
.mutation(async ({ input, ctx }) => {
const port = await finPortById(input.portId);

View File

@@ -9,6 +9,8 @@ import {
findEnvironmentById,
findPostgresById,
findProjectById,
getAccessibleServerIds,
getContainerLogs,
getMountPath,
getServiceContainerCommand,
IS_CLOUD,
@@ -20,7 +22,6 @@ import {
stopService,
stopServiceRemote,
updatePostgresById,
getAccessibleServerIds,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -54,6 +55,12 @@ import { cancelJobs } from "@/server/utils/backup";
export const postgresRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a PostgreSQL database",
description: "Creates a new PostgreSQL database service with the specified configuration, sets up a persistent data volume, and registers it in the project.",
},
})
.input(apiCreatePostgres)
.mutation(async ({ input, ctx }) => {
try {
@@ -120,6 +127,12 @@ export const postgresRouter = createTRPCRouter({
}
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a PostgreSQL database by ID",
description: "Returns the full details of a PostgreSQL database service, including its environment and project configuration.",
},
})
.input(apiFindOnePostgres)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.postgresId, "read");
@@ -138,6 +151,12 @@ export const postgresRouter = createTRPCRouter({
}),
start: protectedProcedure
.meta({
openapi: {
summary: "Start a PostgreSQL database",
description: "Starts the Docker container for the specified PostgreSQL database and sets its status to done.",
},
})
.input(apiFindOnePostgres)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
@@ -163,6 +182,12 @@ export const postgresRouter = createTRPCRouter({
return service;
}),
stop: protectedProcedure
.meta({
openapi: {
summary: "Stop a PostgreSQL database",
description: "Stops the Docker container for the specified PostgreSQL database and sets its status to idle.",
},
})
.input(apiFindOnePostgres)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
@@ -187,6 +212,12 @@ export const postgresRouter = createTRPCRouter({
return postgres;
}),
saveExternalPort: protectedProcedure
.meta({
openapi: {
summary: "Save the external port for a PostgreSQL database",
description: "Updates the external port mapping for the PostgreSQL database and triggers a redeployment. Validates that the port is not already in use.",
},
})
.input(apiSaveExternalPortPostgres)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
@@ -220,6 +251,12 @@ export const postgresRouter = createTRPCRouter({
return postgres;
}),
deploy: protectedProcedure
.meta({
openapi: {
summary: "Deploy a PostgreSQL database",
description: "Triggers a deployment for the specified PostgreSQL database, rebuilding and restarting its Docker container with the current configuration.",
},
})
.input(apiDeployPostgres)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
@@ -275,6 +312,12 @@ export const postgresRouter = createTRPCRouter({
}),
changeStatus: protectedProcedure
.meta({
openapi: {
summary: "Change PostgreSQL database status",
description: "Updates the application status of a PostgreSQL database without starting or stopping the container.",
},
})
.input(apiChangePostgresStatus)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
@@ -293,6 +336,12 @@ export const postgresRouter = createTRPCRouter({
return postgres;
}),
remove: protectedProcedure
.meta({
openapi: {
summary: "Delete a PostgreSQL database",
description: "Removes the PostgreSQL database service, its Docker container, cancels associated backup jobs, and deletes the database record.",
},
})
.input(apiFindOnePostgres)
.mutation(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.postgresId, "delete");
@@ -331,6 +380,12 @@ export const postgresRouter = createTRPCRouter({
return postgres;
}),
saveEnvironment: protectedProcedure
.meta({
openapi: {
summary: "Save environment variables for a PostgreSQL database",
description: "Updates the environment variables for the specified PostgreSQL database service.",
},
})
.input(apiSaveEnvironmentVariablesPostgres)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
@@ -355,6 +410,12 @@ export const postgresRouter = createTRPCRouter({
return true;
}),
reload: protectedProcedure
.meta({
openapi: {
summary: "Reload a PostgreSQL database",
description: "Restarts the PostgreSQL database by stopping and then starting its Docker container.",
},
})
.input(apiResetPostgres)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
@@ -387,6 +448,12 @@ export const postgresRouter = createTRPCRouter({
return true;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a PostgreSQL database",
description: "Updates the configuration of an existing PostgreSQL database service.",
},
})
.input(apiUpdatePostgres)
.mutation(async ({ input, ctx }) => {
const { postgresId, ...rest } = input;
@@ -414,6 +481,12 @@ export const postgresRouter = createTRPCRouter({
return true;
}),
changePassword: protectedProcedure
.meta({
openapi: {
summary: "Change PostgreSQL database password",
description: "Changes the password for the PostgreSQL database user by executing ALTER USER inside the running container and updating the stored password.",
},
})
.input(
z.object({
postgresId: z.string().min(1),
@@ -464,6 +537,12 @@ export const postgresRouter = createTRPCRouter({
return true;
}),
move: protectedProcedure
.meta({
openapi: {
summary: "Move a PostgreSQL database to another environment",
description: "Moves the PostgreSQL database to a different environment within the same project.",
},
})
.input(
z.object({
postgresId: z.string(),
@@ -500,6 +579,12 @@ export const postgresRouter = createTRPCRouter({
return updatedPostgres;
}),
rebuild: protectedProcedure
.meta({
openapi: {
summary: "Rebuild a PostgreSQL database",
description: "Rebuilds the PostgreSQL database Docker container from scratch, pulling the latest image and recreating the service.",
},
})
.input(apiRebuildPostgres)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
@@ -516,6 +601,12 @@ export const postgresRouter = createTRPCRouter({
return true;
}),
search: protectedProcedure
.meta({
openapi: {
summary: "Search PostgreSQL databases",
description: "Returns a paginated list of PostgreSQL databases matching the given filters. Supports searching by name, appName, description, project, and environment.",
},
})
.input(
z.object({
q: z.string().optional(),
@@ -614,4 +705,45 @@ export const postgresRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.meta({
openapi: {
summary: "Read PostgreSQL container logs",
description: "Retrieves the Docker container logs for the specified PostgreSQL database, with support for tail count, time-based filtering, and text search.",
},
})
.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

@@ -16,6 +16,12 @@ import { createTRPCRouter, protectedProcedure } from "../trpc";
export const previewDeploymentRouter = createTRPCRouter({
all: protectedProcedure
.meta({
openapi: {
summary: "List preview deployments",
description: "Returns all preview deployments associated with the given application.",
},
})
.input(apiFindAllByApplication)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -25,6 +31,12 @@ export const previewDeploymentRouter = createTRPCRouter({
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a preview deployment",
description: "Returns the details of a specific preview deployment by its ID.",
},
})
.input(z.object({ previewDeploymentId: z.string() }))
.query(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
@@ -39,6 +51,12 @@ export const previewDeploymentRouter = createTRPCRouter({
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a preview deployment",
description: "Permanently removes a preview deployment and its associated resources.",
},
})
.input(z.object({ previewDeploymentId: z.string() }))
.mutation(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
@@ -59,6 +77,12 @@ export const previewDeploymentRouter = createTRPCRouter({
}),
redeploy: protectedProcedure
.meta({
openapi: {
summary: "Redeploy a preview deployment",
description: "Triggers a rebuild of an existing preview deployment by adding a new job to the deployment queue.",
},
})
.input(
z.object({
previewDeploymentId: z.string(),

View File

@@ -67,6 +67,12 @@ import {
export const projectRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a project",
description: "Creates a new project in the current organization with a default environment. Validates server availability for cloud deployments.",
},
})
.input(apiCreateProject)
.mutation(async ({ ctx, input }) => {
try {
@@ -106,6 +112,12 @@ export const projectRouter = createTRPCRouter({
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a project",
description: "Retrieves a project by its ID with all environments and services. Filters services based on the user's access permissions.",
},
})
.input(apiFindOneProject)
.query(async ({ input, ctx }) => {
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
@@ -193,7 +205,14 @@ export const projectRouter = createTRPCRouter({
}
return project;
}),
all: protectedProcedure.query(async ({ ctx }) => {
all: protectedProcedure
.meta({
openapi: {
summary: "List all projects",
description: "Returns all projects in the current organization with their environments and services. Filters results based on the user's access permissions.",
},
})
.query(async ({ ctx }) => {
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const { accessedProjects, accessedEnvironments, accessedServices } =
await findMemberByUserId(ctx.user.id, ctx.session.activeOrganizationId);
@@ -375,7 +394,14 @@ export const projectRouter = createTRPCRouter({
});
}),
allForPermissions: withPermission("member", "update").query(
allForPermissions: withPermission("member", "update")
.meta({
openapi: {
summary: "List all projects for permissions",
description: "Returns all projects with their environments and services for the permissions management UI. Requires member update permission.",
},
})
.query(
async ({ ctx }) => {
return await db.query.projects.findMany({
where: eq(projects.organizationId, ctx.session.activeOrganizationId),
@@ -488,6 +514,12 @@ export const projectRouter = createTRPCRouter({
),
search: protectedProcedure
.meta({
openapi: {
summary: "Search projects",
description: "Searches projects by name or description with pagination. Respects project-level access control for non-admin users.",
},
})
.input(
z.object({
q: z.string().optional(),
@@ -565,6 +597,12 @@ export const projectRouter = createTRPCRouter({
}),
remove: protectedProcedure
.meta({
openapi: {
summary: "Delete a project",
description: "Permanently deletes a project and all its associated environments, services, and resources.",
},
})
.input(apiRemoveProject)
.mutation(async ({ input, ctx }) => {
try {
@@ -592,6 +630,12 @@ export const projectRouter = createTRPCRouter({
}
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a project",
description: "Updates a project's name, description, or environment variables. Validates organization ownership and project-level access permissions.",
},
})
.input(apiUpdateProject)
.mutation(async ({ input, ctx }) => {
try {
@@ -640,6 +684,12 @@ export const projectRouter = createTRPCRouter({
}
}),
duplicate: protectedProcedure
.meta({
openapi: {
summary: "Duplicate a project or environment",
description: "Duplicates services from a source environment into a new project or into the same project. Copies applications, compose services, databases, and their associated domains, mounts, ports, and backups.",
},
})
.input(
z.object({
sourceEnvironmentId: z.string(),

View File

@@ -15,6 +15,12 @@ import {
export const redirectsRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a redirect",
description: "Creates a new redirect rule for an application using a regex pattern. Logs an audit entry.",
},
})
.input(apiCreateRedirect)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -31,6 +37,12 @@ export const redirectsRouter = createTRPCRouter({
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a redirect",
description: "Returns a single redirect rule by its ID.",
},
})
.input(apiFindOneRedirect)
.query(async ({ input, ctx }) => {
const redirect = await findRedirectById(input.redirectId);
@@ -41,6 +53,12 @@ export const redirectsRouter = createTRPCRouter({
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a redirect",
description: "Deletes a redirect rule by its ID and logs an audit entry.",
},
})
.input(apiFindOneRedirect)
.mutation(async ({ input, ctx }) => {
const redirect = await findRedirectById(input.redirectId);
@@ -57,6 +75,12 @@ export const redirectsRouter = createTRPCRouter({
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a redirect",
description: "Updates an existing redirect rule's configuration and logs an audit entry.",
},
})
.input(apiUpdateRedirect)
.mutation(async ({ input, ctx }) => {
const redirect = await findRedirectById(input.redirectId);

View File

@@ -8,6 +8,7 @@ import {
findEnvironmentById,
findProjectById,
findRedisById,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -50,6 +51,12 @@ import {
} from "@/server/db/schema";
export const redisRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a Redis database",
description: "Creates a new Redis database service with the specified configuration, sets up a persistent data volume, and registers it in the project.",
},
})
.input(apiCreateRedis)
.mutation(async ({ input, ctx }) => {
try {
@@ -107,6 +114,12 @@ export const redisRouter = createTRPCRouter({
}
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a Redis database by ID",
description: "Returns the full details of a Redis database service, including its environment and project configuration.",
},
})
.input(apiFindOneRedis)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.redisId, "read");
@@ -125,6 +138,12 @@ export const redisRouter = createTRPCRouter({
}),
start: protectedProcedure
.meta({
openapi: {
summary: "Start a Redis database",
description: "Starts the Docker container for the specified Redis database and sets its status to done.",
},
})
.input(apiFindOneRedis)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
@@ -150,6 +169,12 @@ export const redisRouter = createTRPCRouter({
return redis;
}),
reload: protectedProcedure
.meta({
openapi: {
summary: "Reload a Redis database",
description: "Restarts the Redis database by stopping and then starting its Docker container.",
},
})
.input(apiResetRedis)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
@@ -183,6 +208,12 @@ export const redisRouter = createTRPCRouter({
}),
stop: protectedProcedure
.meta({
openapi: {
summary: "Stop a Redis database",
description: "Stops the Docker container for the specified Redis database and sets its status to idle.",
},
})
.input(apiFindOneRedis)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
@@ -207,6 +238,12 @@ export const redisRouter = createTRPCRouter({
return redis;
}),
saveExternalPort: protectedProcedure
.meta({
openapi: {
summary: "Save the external port for a Redis database",
description: "Updates the external port mapping for the Redis database and triggers a redeployment. Validates that the port is not already in use.",
},
})
.input(apiSaveExternalPortRedis)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
@@ -240,6 +277,12 @@ export const redisRouter = createTRPCRouter({
return redis;
}),
deploy: protectedProcedure
.meta({
openapi: {
summary: "Deploy a Redis database",
description: "Triggers a deployment for the specified Redis database, rebuilding and restarting its Docker container with the current configuration.",
},
})
.input(apiDeployRedis)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
@@ -292,6 +335,12 @@ export const redisRouter = createTRPCRouter({
}
}),
changeStatus: protectedProcedure
.meta({
openapi: {
summary: "Change Redis database status",
description: "Updates the application status of a Redis database without starting or stopping the container.",
},
})
.input(apiChangeRedisStatus)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
@@ -310,6 +359,12 @@ export const redisRouter = createTRPCRouter({
return mongo;
}),
remove: protectedProcedure
.meta({
openapi: {
summary: "Delete a Redis database",
description: "Removes the Redis database service, its Docker container, and deletes the database record.",
},
})
.input(apiFindOneRedis)
.mutation(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.redisId, "delete");
@@ -345,6 +400,12 @@ export const redisRouter = createTRPCRouter({
return redis;
}),
saveEnvironment: protectedProcedure
.meta({
openapi: {
summary: "Save environment variables for a Redis database",
description: "Updates the environment variables for the specified Redis database service.",
},
})
.input(apiSaveEnvironmentVariablesRedis)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
@@ -369,6 +430,12 @@ export const redisRouter = createTRPCRouter({
return true;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a Redis database",
description: "Updates the configuration of an existing Redis database service.",
},
})
.input(apiUpdateRedis)
.mutation(async ({ input, ctx }) => {
const { redisId, ...rest } = input;
@@ -395,6 +462,12 @@ export const redisRouter = createTRPCRouter({
return true;
}),
changePassword: protectedProcedure
.meta({
openapi: {
summary: "Change Redis database password",
description: "Changes the password for the Redis database by executing CONFIG SET requirepass inside the running container and updating the stored password.",
},
})
.input(
z.object({
redisId: z.string().min(1),
@@ -445,6 +518,12 @@ export const redisRouter = createTRPCRouter({
return true;
}),
move: protectedProcedure
.meta({
openapi: {
summary: "Move a Redis database to another environment",
description: "Moves the Redis database to a different environment within the same project.",
},
})
.input(
z.object({
redisId: z.string(),
@@ -481,6 +560,12 @@ export const redisRouter = createTRPCRouter({
return updatedRedis;
}),
rebuild: protectedProcedure
.meta({
openapi: {
summary: "Rebuild a Redis database",
description: "Rebuilds the Redis database Docker container from scratch, pulling the latest image and recreating the service.",
},
})
.input(apiRebuildRedis)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
@@ -496,6 +581,12 @@ export const redisRouter = createTRPCRouter({
return true;
}),
search: protectedProcedure
.meta({
openapi: {
summary: "Search Redis databases",
description: "Returns a paginated list of Redis databases matching the given filters. Supports searching by name, appName, description, project, and environment.",
},
})
.input(
z.object({
q: z.string().optional(),
@@ -587,4 +678,45 @@ export const redisRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.meta({
openapi: {
summary: "Read Redis container logs",
description: "Retrieves the Docker container logs for the specified Redis database, with support for tail count, time-based filtering, and text search.",
},
})
.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

@@ -23,6 +23,12 @@ import {
import { createTRPCRouter, withPermission } from "../trpc";
export const registryRouter = createTRPCRouter({
create: withPermission("registry", "create")
.meta({
openapi: {
summary: "Create registry",
description: "Creates a new Docker registry entry for the current organization and logs an audit event.",
},
})
.input(apiCreateRegistry)
.mutation(async ({ ctx, input }) => {
const reg = await createRegistry(input, ctx.session.activeOrganizationId);
@@ -35,6 +41,12 @@ export const registryRouter = createTRPCRouter({
return reg;
}),
remove: withPermission("registry", "delete")
.meta({
openapi: {
summary: "Delete registry",
description: "Removes a Docker registry entry by ID. Verifies organization ownership and logs an audit event before deletion.",
},
})
.input(apiRemoveRegistry)
.mutation(async ({ ctx, input }) => {
const registry = await findRegistryById(input.registryId);
@@ -53,6 +65,12 @@ export const registryRouter = createTRPCRouter({
return await removeRegistry(input.registryId);
}),
update: withPermission("registry", "create")
.meta({
openapi: {
summary: "Update registry",
description: "Updates an existing Docker registry entry. Verifies organization ownership before applying changes and logs an audit event.",
},
})
.input(apiUpdateRegistry)
.mutation(async ({ input, ctx }) => {
const { registryId, ...rest } = input;
@@ -82,13 +100,26 @@ export const registryRouter = createTRPCRouter({
});
return true;
}),
all: withPermission("registry", "read").query(async ({ ctx }) => {
all: withPermission("registry", "read")
.meta({
openapi: {
summary: "List all registries",
description: "Returns all Docker registry entries for the current organization.",
},
})
.query(async ({ ctx }) => {
const registryResponse = await db.query.registry.findMany({
where: eq(registry.organizationId, ctx.session.activeOrganizationId),
});
return registryResponse;
}),
one: withPermission("registry", "read")
.meta({
openapi: {
summary: "Get registry",
description: "Returns a single Docker registry entry by ID. Verifies the caller belongs to the same organization.",
},
})
.input(apiFindOneRegistry)
.query(async ({ input, ctx }) => {
const registry = await findRegistryById(input.registryId);
@@ -101,6 +132,12 @@ export const registryRouter = createTRPCRouter({
return registry;
}),
testRegistry: withPermission("registry", "read")
.meta({
openapi: {
summary: "Test registry credentials",
description: "Attempts a docker login with the provided credentials to verify the registry URL, username, and password are valid. Can run locally or on a remote server.",
},
})
.input(apiTestRegistry)
.mutation(async ({ input }) => {
try {
@@ -143,6 +180,12 @@ export const registryRouter = createTRPCRouter({
}
}),
testRegistryById: withPermission("registry", "read")
.meta({
openapi: {
summary: "Test registry connection by ID",
description: "Looks up a saved registry by ID and attempts a docker login with its stored credentials. Verifies organization ownership before testing.",
},
})
.input(apiTestRegistryById)
.mutation(async ({ input, ctx }) => {
try {

View File

@@ -11,6 +11,12 @@ import { createTRPCRouter, protectedProcedure } from "../trpc";
export const rollbackRouter = createTRPCRouter({
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a rollback",
description: "Permanently removes a rollback record by its ID.",
},
})
.input(apiFindOneRollback)
.mutation(async ({ input, ctx }) => {
try {
@@ -40,6 +46,12 @@ export const rollbackRouter = createTRPCRouter({
}
}),
rollback: protectedProcedure
.meta({
openapi: {
summary: "Perform a rollback",
description: "Rolls back an application to a previous deployment by restoring its Docker image and redeploying.",
},
})
.input(apiFindOneRollback)
.mutation(async ({ input, ctx }) => {
try {

View File

@@ -22,6 +22,12 @@ import { removeJob, schedule } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const scheduleRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a scheduled job",
description: "Creates a new scheduled job for an application or compose service. If enabled, the job is automatically scheduled using the provided cron expression and timezone.",
},
})
.input(createScheduleSchema)
.mutation(async ({ input, ctx }) => {
const serviceId = input.applicationId || input.composeId;
@@ -54,6 +60,12 @@ export const scheduleRouter = createTRPCRouter({
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a scheduled job",
description: "Updates an existing scheduled job configuration. Reschedules or removes the job depending on the enabled state.",
},
})
.input(updateScheduleSchema)
.mutation(async ({ input, ctx }) => {
const existingSchedule = await findScheduleById(input.scheduleId);
@@ -99,6 +111,12 @@ export const scheduleRouter = createTRPCRouter({
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a scheduled job",
description: "Permanently removes a scheduled job and unschedules any associated cron job.",
},
})
.input(z.object({ scheduleId: z.string() }))
.mutation(async ({ input, ctx }) => {
const scheduleItem = await findScheduleById(input.scheduleId);
@@ -129,6 +147,12 @@ export const scheduleRouter = createTRPCRouter({
}),
list: protectedProcedure
.meta({
openapi: {
summary: "List scheduled jobs",
description: "Returns all scheduled jobs for a given service (application, compose, server, or dokploy-server), including their deployment history.",
},
})
.input(
z.object({
id: z.string(),
@@ -170,6 +194,12 @@ export const scheduleRouter = createTRPCRouter({
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a scheduled job",
description: "Returns the details of a specific scheduled job by its ID.",
},
})
.input(z.object({ scheduleId: z.string() }))
.query(async ({ input, ctx }) => {
const schedule = await findScheduleById(input.scheduleId);
@@ -183,6 +213,12 @@ export const scheduleRouter = createTRPCRouter({
}),
runManually: protectedProcedure
.meta({
openapi: {
summary: "Run a scheduled job manually",
description: "Immediately executes a scheduled job outside of its normal cron schedule.",
},
})
.input(z.object({ scheduleId: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const scheduleItem = await findScheduleById(input.scheduleId);

View File

@@ -15,6 +15,12 @@ import {
export const securityRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a security entry",
description: "Creates a new HTTP basic auth security entry for an application with the provided username and password. Logs an audit entry.",
},
})
.input(apiCreateSecurity)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
@@ -31,6 +37,12 @@ export const securityRouter = createTRPCRouter({
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a security entry",
description: "Returns a single HTTP basic auth security entry by its ID.",
},
})
.input(apiFindOneSecurity)
.query(async ({ input, ctx }) => {
const security = await findSecurityById(input.securityId);
@@ -41,6 +53,12 @@ export const securityRouter = createTRPCRouter({
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a security entry",
description: "Deletes an HTTP basic auth security entry by its ID and logs an audit entry.",
},
})
.input(apiFindOneSecurity)
.mutation(async ({ input, ctx }) => {
const security = await findSecurityById(input.securityId);
@@ -57,6 +75,12 @@ export const securityRouter = createTRPCRouter({
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a security entry",
description: "Updates an existing HTTP basic auth security entry's configuration and logs an audit entry.",
},
})
.input(apiUpdateSecurity)
.mutation(async ({ input, ctx }) => {
const security = await findSecurityById(input.securityId);

View File

@@ -48,6 +48,12 @@ import {
export const serverRouter = createTRPCRouter({
create: withPermission("server", "create")
.meta({
openapi: {
summary: "Create a server",
description: "Creates a new server in the organization. In cloud mode, enforces the user's server quantity limit. Returns the newly created server.",
},
})
.input(apiCreateServer)
.mutation(async ({ ctx, input }) => {
try {
@@ -80,6 +86,12 @@ export const serverRouter = createTRPCRouter({
}),
one: withPermission("server", "read")
.meta({
openapi: {
summary: "Get a server",
description: "Retrieves a single server by its ID. Validates that the user has access to the server within their organization.",
},
})
.input(apiFindOneServer)
.query(async ({ input, ctx }) => {
const server = await findServerById(input.serverId);
@@ -101,13 +113,26 @@ export const serverRouter = createTRPCRouter({
return server;
}),
getDefaultCommand: withPermission("server", "read")
.meta({
openapi: {
summary: "Get default server command",
description: "Returns the default setup command for a server. The command varies depending on whether the server is a build server or a deploy server.",
},
})
.input(apiFindOneServer)
.query(async ({ input }) => {
const server = await findServerById(input.serverId);
const isBuildServer = server.serverType === "build";
return defaultCommand(isBuildServer);
}),
all: withPermission("server", "read").query(async ({ ctx }) => {
all: withPermission("server", "read")
.meta({
openapi: {
summary: "List all servers",
description: "Returns all servers in the current organization along with a count of associated services (applications, compose, databases). Results are filtered by the user's accessible server permissions.",
},
})
.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleServerIds(ctx.session);
const result = await db
@@ -130,6 +155,12 @@ export const serverRouter = createTRPCRouter({
return result.filter((s) => accessibleIds.has(s.serverId));
}),
allForPermissions: withPermission("member", "update")
.meta({
openapi: {
summary: "List all servers for permissions",
description: "Returns a minimal list of servers (ID, name, IP, type) used for configuring member permissions. Requires a valid enterprise license.",
},
})
.use(async ({ ctx, next }) => {
const licensed = await hasValidLicense(ctx.session.activeOrganizationId);
if (!licensed) {
@@ -152,7 +183,14 @@ export const serverRouter = createTRPCRouter({
where: eq(server.organizationId, ctx.session.activeOrganizationId),
});
}),
count: protectedProcedure.query(async ({ ctx }) => {
count: protectedProcedure
.meta({
openapi: {
summary: "Get server count",
description: "Returns the total number of servers across all organizations owned by the current user.",
},
})
.query(async ({ ctx }) => {
const organizations = await db.query.organization.findMany({
where: eq(organization.ownerId, ctx.user.id),
with: {
@@ -164,7 +202,14 @@ export const serverRouter = createTRPCRouter({
return servers.length ?? 0;
}),
withSSHKey: withPermission("server", "read").query(async ({ ctx }) => {
withSSHKey: withPermission("server", "read")
.meta({
openapi: {
summary: "List servers with SSH keys",
description: "Returns all deploy-type servers that have an SSH key configured. In cloud mode, only active servers are included. Results are filtered by the user's accessible server permissions.",
},
})
.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleServerIds(ctx.session);
const result = await db.query.server.findMany({
@@ -184,7 +229,14 @@ export const serverRouter = createTRPCRouter({
});
return result.filter((s) => accessibleIds.has(s.serverId));
}),
buildServers: withPermission("server", "read").query(async ({ ctx }) => {
buildServers: withPermission("server", "read")
.meta({
openapi: {
summary: "List build servers",
description: "Returns all build-type servers that have an SSH key configured. In cloud mode, only active servers are included. Results are filtered by the user's accessible server permissions.",
},
})
.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleServerIds(ctx.session);
const result = await db.query.server.findMany({
@@ -205,6 +257,12 @@ export const serverRouter = createTRPCRouter({
return result.filter((s) => accessibleIds.has(s.serverId));
}),
setup: withPermission("server", "create")
.meta({
openapi: {
summary: "Setup a server",
description: "Runs the initial setup process on a remote server, installing required dependencies and configuring Docker. An audit log entry is created.",
},
})
.input(apiFindOneServer)
.mutation(async ({ input, ctx }) => {
try {
@@ -256,6 +314,12 @@ export const serverRouter = createTRPCRouter({
}
}),
validate: withPermission("server", "read")
.meta({
openapi: {
summary: "Validate server configuration",
description: "Checks the server for required tools and configuration including Docker, Rclone, Nixpacks, Buildpacks, Railpack, Swarm mode, network setup, and privilege mode.",
},
})
.input(apiFindOneServer)
.query(async ({ input, ctx }) => {
try {
@@ -304,6 +368,12 @@ export const serverRouter = createTRPCRouter({
}),
security: withPermission("server", "read")
.meta({
openapi: {
summary: "Get server security audit",
description: "Performs a security audit on the server, checking UFW firewall, SSH configuration, non-root user setup, unattended upgrades, and Fail2Ban status.",
},
})
.input(apiFindOneServer)
.query(async ({ input, ctx }) => {
try {
@@ -354,6 +424,12 @@ export const serverRouter = createTRPCRouter({
}
}),
setupMonitoring: withPermission("server", "create")
.meta({
openapi: {
summary: "Setup server monitoring",
description: "Configures and deploys the monitoring agent on a server with the specified metrics configuration including refresh rates, retention, thresholds, and container service filters.",
},
})
.input(apiUpdateServerMonitoring)
.mutation(async ({ input, ctx }) => {
try {
@@ -402,6 +478,12 @@ export const serverRouter = createTRPCRouter({
}
}),
remove: withPermission("server", "delete")
.meta({
openapi: {
summary: "Remove a server",
description: "Deletes a server and removes all associated deployments. Fails if the server has active services. In cloud mode, updates the user's server quantity allocation.",
},
})
.input(apiRemoveServer)
.mutation(async ({ input, ctx }) => {
try {
@@ -435,6 +517,12 @@ export const serverRouter = createTRPCRouter({
}
}),
update: withPermission("server", "create")
.meta({
openapi: {
summary: "Update a server",
description: "Updates the configuration of an existing server. Fails if the server is inactive. An audit log entry is created for the update.",
},
})
.input(apiUpdateServer)
.mutation(async ({ input, ctx }) => {
try {
@@ -467,14 +555,28 @@ export const serverRouter = createTRPCRouter({
throw error;
}
}),
publicIp: protectedProcedure.query(async () => {
publicIp: protectedProcedure
.meta({
openapi: {
summary: "Get public IP address",
description: "Returns the public IP address of the local server. Returns an empty string in cloud mode.",
},
})
.query(async () => {
if (IS_CLOUD) {
return "";
}
const ip = await getPublicIpWithFallback();
return ip;
}),
getServerTime: protectedProcedure.query(() => {
getServerTime: protectedProcedure
.meta({
openapi: {
summary: "Get server time",
description: "Returns the current server time and timezone. Returns null in cloud mode.",
},
})
.query(() => {
if (IS_CLOUD) {
return null;
}
@@ -484,6 +586,12 @@ export const serverRouter = createTRPCRouter({
};
}),
getServerMetrics: withPermission("monitoring", "read")
.meta({
openapi: {
summary: "Get server metrics",
description: "Fetches monitoring metrics (CPU, memory, disk, network) from the server's monitoring agent endpoint. Requires the monitoring service to be configured and running.",
},
})
.input(
z.object({
url: z.string(),

File diff suppressed because it is too large Load Diff

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,
@@ -21,6 +25,12 @@ import {
export const sshRouter = createTRPCRouter({
create: withPermission("sshKeys", "create")
.meta({
openapi: {
summary: "Create SSH key",
description: "Stores a new SSH key for the current organization and logs an audit event.",
},
})
.input(apiCreateSshKey)
.mutation(async ({ input, ctx }) => {
try {
@@ -42,6 +52,12 @@ export const sshRouter = createTRPCRouter({
}
}),
remove: withPermission("sshKeys", "delete")
.meta({
openapi: {
summary: "Delete SSH key",
description: "Removes an SSH key by ID. Verifies organization ownership and logs an audit event before deletion.",
},
})
.input(apiRemoveSshKey)
.mutation(async ({ input, ctx }) => {
try {
@@ -65,6 +81,12 @@ export const sshRouter = createTRPCRouter({
}
}),
one: withPermission("sshKeys", "read")
.meta({
openapi: {
summary: "Get SSH key",
description: "Returns a single SSH key by ID. Verifies the caller belongs to the same organization.",
},
})
.input(apiFindOneSshKey)
.query(async ({ input, ctx }) => {
const sshKey = await findSSHKeyById(input.sshKeyId);
@@ -77,18 +99,54 @@ export const sshRouter = createTRPCRouter({
}
return sshKey;
}),
all: withPermission("sshKeys", "read").query(async ({ ctx }) => {
all: withPermission("sshKeys", "read")
.meta({
openapi: {
summary: "List all SSH keys",
description: "Returns all SSH keys for the current organization, ordered by creation date descending.",
},
})
.query(async ({ ctx }) => {
return await db.query.sshKeys.findMany({
where: eq(sshKeys.organizationId, ctx.session.activeOrganizationId),
orderBy: desc(sshKeys.createdAt),
});
}),
allForApps: protectedProcedure
.meta({
openapi: {
summary: "List SSH keys for app selection",
description: "Returns a lightweight list of SSH keys (ID and name only) for the current organization, suitable for dropdown selectors in application forms.",
},
})
.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")
.meta({
openapi: {
summary: "Generate SSH key pair",
description: "Generates a new SSH key pair of the specified type (RSA, ED25519, etc.) and returns both public and private keys.",
},
})
.input(apiGenerateSSHKey)
.mutation(async ({ input }) => {
return await generateSSHKey(input.type);
}),
update: withPermission("sshKeys", "create")
.meta({
openapi: {
summary: "Update SSH key",
description: "Updates an existing SSH key. Verifies organization ownership before applying changes and logs an audit event.",
},
})
.input(apiUpdateSshKey)
.mutation(async ({ input, ctx }) => {
try {

View File

@@ -30,7 +30,14 @@ import {
export const stripeRouter = createTRPCRouter({
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
getCurrentPlan: protectedProcedure
.meta({
openapi: {
summary: "Get current billing plan",
description: "Returns the active Stripe billing plan (hobby, startup, or legacy) for the caller's organization owner. Returns null if not on cloud or no subscription exists.",
},
})
.query(async ({ ctx }) => {
if (!IS_CLOUD) return null;
const owner = await findUserById(ctx.user.ownerId);
if (!owner?.stripeCustomerId) return null;
@@ -71,7 +78,14 @@ export const stripeRouter = createTRPCRouter({
return null;
}),
getProducts: adminProcedure.query(async ({ ctx }) => {
getProducts: adminProcedure
.meta({
openapi: {
summary: "List Stripe products and subscriptions",
description: "Returns available Stripe products, the user's active subscriptions, current plan tier, billing interval, and price amount.",
},
})
.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const stripeCustomerId = user.stripeCustomerId;
@@ -162,6 +176,12 @@ export const stripeRouter = createTRPCRouter({
};
}),
createCheckoutSession: adminProcedure
.meta({
openapi: {
summary: "Create Stripe checkout session",
description: "Creates a Stripe checkout session for subscribing to a billing plan. Handles customer creation or reuse and returns the session ID for redirect.",
},
})
.input(
z
.object({
@@ -205,11 +225,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`,
@@ -217,7 +242,14 @@ export const stripeRouter = createTRPCRouter({
return { sessionId: session.id };
}),
createCustomerPortalSession: adminProcedure.mutation(async ({ ctx }) => {
createCustomerPortalSession: adminProcedure
.meta({
openapi: {
summary: "Create Stripe customer portal session",
description: "Creates a Stripe billing portal session URL so the user can manage their subscription, payment methods, and invoices.",
},
})
.mutation(async ({ ctx }) => {
// Use the organization's owner account for billing portal
const owner = await findUserById(ctx.user.ownerId);
@@ -248,6 +280,12 @@ export const stripeRouter = createTRPCRouter({
}),
upgradeSubscription: adminProcedure
.meta({
openapi: {
summary: "Upgrade subscription",
description: "Upgrades or changes the current Stripe subscription to a different tier or server quantity. Applies prorated charges for the billing period change.",
},
})
.input(
z
.object({
@@ -319,7 +357,14 @@ export const stripeRouter = createTRPCRouter({
return { ok: true };
}),
canCreateMoreServers: withPermission("server", "create").query(
canCreateMoreServers: withPermission("server", "create")
.meta({
openapi: {
summary: "Check server creation quota",
description: "Returns whether the organization can create more servers based on their subscription's server quantity limit. Always returns true for self-hosted instances.",
},
})
.query(
async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const servers = await findServersByUserId(user.id);
@@ -332,7 +377,36 @@ export const stripeRouter = createTRPCRouter({
},
),
getInvoices: adminProcedure.query(async ({ ctx }) => {
updateInvoiceNotifications: adminProcedure
.meta({
openapi: {
summary: "Update invoice notification preference",
description: "Enables or disables email notifications for invoice events. Only available on Dokploy Cloud.",
},
})
.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
.meta({
openapi: {
summary: "List invoices",
description: "Returns up to 100 Stripe invoices for the organization owner, including status, amounts, and download links.",
},
})
.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const stripeCustomerId = user.stripeCustomerId;

View File

@@ -13,6 +13,12 @@ import { containerIdRegex } from "./docker";
export const swarmRouter = createTRPCRouter({
getNodes: withPermission("server", "read")
.meta({
openapi: {
summary: "Get Swarm nodes",
description: "Retrieves all nodes in the Docker Swarm. Optionally targets a remote server by ID.",
},
})
.input(
z.object({
serverId: z.string().optional(),
@@ -22,11 +28,23 @@ export const swarmRouter = createTRPCRouter({
return await getSwarmNodes(input.serverId);
}),
getNodeInfo: withPermission("server", "read")
.meta({
openapi: {
summary: "Get Swarm node info",
description: "Retrieves detailed information about a specific Docker Swarm node by its node ID. Optionally targets a remote server.",
},
})
.input(z.object({ nodeId: z.string(), serverId: z.string().optional() }))
.query(async ({ input }) => {
return await getNodeInfo(input.nodeId, input.serverId);
}),
getNodeApps: withPermission("server", "read")
.meta({
openapi: {
summary: "Get Swarm node applications",
description: "Retrieves all applications (services) running across Docker Swarm nodes. Optionally targets a remote server.",
},
})
.input(
z.object({
serverId: z.string().optional(),
@@ -58,6 +76,12 @@ export const swarmRouter = createTRPCRouter({
return await getApplicationInfo(input.appName, input.serverId);
}),
getContainerStats: withPermission("server", "read")
.meta({
openapi: {
summary: "Get container stats",
description: "Retrieves resource usage statistics for all containers. Optionally targets a remote server and validates organization access.",
},
})
.input(
z.object({
serverId: z.string().optional(),

View File

@@ -16,6 +16,12 @@ import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const tagRouter = createTRPCRouter({
create: withPermission("tag", "create")
.meta({
openapi: {
summary: "Create tag",
description: "Creates a new tag with a name and color for the current organization. Tag names must be unique within the organization.",
},
})
.input(apiCreateTag)
.mutation(async ({ input, ctx }) => {
try {
@@ -47,7 +53,14 @@ export const tagRouter = createTRPCRouter({
}
}),
all: protectedProcedure.query(async ({ ctx }) => {
all: protectedProcedure
.meta({
openapi: {
summary: "List all tags",
description: "Returns all tags for the current organization, ordered alphabetically by name.",
},
})
.query(async ({ ctx }) => {
try {
const organizationTags = await db.query.tags.findMany({
where: eq(tags.organizationId, ctx.session.activeOrganizationId),
@@ -64,7 +77,15 @@ export const tagRouter = createTRPCRouter({
}
}),
one: protectedProcedure.input(apiFindOneTag).query(async ({ input, ctx }) => {
one: protectedProcedure
.meta({
openapi: {
summary: "Get tag",
description: "Returns a single tag by ID. Only returns tags belonging to the caller's organization.",
},
})
.input(apiFindOneTag)
.query(async ({ input, ctx }) => {
try {
const tag = await db.query.tags.findFirst({
where: and(
@@ -94,6 +115,12 @@ export const tagRouter = createTRPCRouter({
}),
update: withPermission("tag", "update")
.meta({
openapi: {
summary: "Update tag",
description: "Updates an existing tag's name and/or color. Verifies the tag belongs to the caller's organization. Tag names must remain unique.",
},
})
.input(apiUpdateTag)
.mutation(async ({ input, ctx }) => {
try {
@@ -144,6 +171,12 @@ export const tagRouter = createTRPCRouter({
}),
remove: withPermission("tag", "delete")
.meta({
openapi: {
summary: "Delete tag",
description: "Deletes a tag by ID. Cascade-deletes all project-tag associations. Verifies the tag belongs to the caller's organization.",
},
})
.input(apiRemoveTag)
.mutation(async ({ input, ctx }) => {
try {
@@ -179,6 +212,12 @@ export const tagRouter = createTRPCRouter({
}),
assignToProject: protectedProcedure
.meta({
openapi: {
summary: "Assign tag to project",
description: "Associates a tag with a project. Verifies that both the tag and project belong to the caller's organization and that the caller has project access.",
},
})
.input(
z.object({
projectId: z.string().min(1),
@@ -267,6 +306,12 @@ export const tagRouter = createTRPCRouter({
}),
removeFromProject: protectedProcedure
.meta({
openapi: {
summary: "Remove tag from project",
description: "Removes a tag-project association. Verifies that both the tag and project belong to the caller's organization and that the caller has project access.",
},
})
.input(
z.object({
projectId: z.string().min(1),
@@ -347,6 +392,12 @@ export const tagRouter = createTRPCRouter({
}),
bulkAssign: protectedProcedure
.meta({
openapi: {
summary: "Bulk assign tags to project",
description: "Replaces all tag associations for a project with the provided list of tag IDs. Removes existing associations first, then inserts the new set.",
},
})
.input(
z.object({
projectId: z.string().min(1),

View File

@@ -60,7 +60,14 @@ const apiCreateApiKey = z.object({
});
export const userRouter = createTRPCRouter({
all: withPermission("member", "read").query(async ({ ctx }) => {
all: withPermission("member", "read")
.meta({
openapi: {
summary: "List all organization members",
description: "Retrieve all members of the current active organization, including their associated user data, ordered by creation date.",
},
})
.query(async ({ ctx }) => {
return await db.query.member.findMany({
where: eq(member.organizationId, ctx.session.activeOrganizationId),
with: {
@@ -70,6 +77,12 @@ export const userRouter = createTRPCRouter({
});
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a user by ID",
description: "Retrieve a specific user's membership and profile within the active organization. Users can view their own data; admins and owners can view any member. Requires member.update permission for non-self lookups.",
},
})
.input(
z.object({
userId: z.string(),
@@ -114,7 +127,14 @@ export const userRouter = createTRPCRouter({
return memberResult;
}),
session: publicProcedure.query(async ({ ctx }) => {
session: publicProcedure
.meta({
openapi: {
summary: "Get current session",
description: "Return the current user's ID and active organization ID from the session. Returns null if no valid session exists.",
},
})
.query(async ({ ctx }) => {
if (!ctx.user || !ctx.session || !ctx.session.activeOrganizationId) {
return null;
}
@@ -127,7 +147,14 @@ export const userRouter = createTRPCRouter({
},
};
}),
get: protectedProcedure.query(async ({ ctx }) => {
get: protectedProcedure
.meta({
openapi: {
summary: "Get current user profile",
description: "Retrieve the current authenticated user's membership record including user profile and API keys for the active organization.",
},
})
.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
eq(member.userId, ctx.user.id),
@@ -144,10 +171,24 @@ export const userRouter = createTRPCRouter({
return memberResult;
}),
getPermissions: protectedProcedure.query(async ({ ctx }) => {
getPermissions: protectedProcedure
.meta({
openapi: {
summary: "Get resolved permissions",
description: "Return the fully resolved permissions for the current user in the active organization, combining role-based and custom permissions.",
},
})
.query(async ({ ctx }) => {
return resolvePermissions(ctx);
}),
haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
haveRootAccess: protectedProcedure
.meta({
openapi: {
summary: "Check root access",
description: "Check whether the current user has root admin access. Only returns true in cloud mode for the designated admin user or impersonating sessions.",
},
})
.query(async ({ ctx }) => {
if (!IS_CLOUD) {
return false;
}
@@ -159,7 +200,14 @@ export const userRouter = createTRPCRouter({
}
return false;
}),
getBackups: adminProcedure.query(async ({ ctx }) => {
getBackups: adminProcedure
.meta({
openapi: {
summary: "Get user backups",
description: "Retrieve the current admin user's backup configurations including destinations, deployments, and API keys.",
},
})
.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
eq(member.userId, ctx.user.id),
@@ -182,8 +230,14 @@ export const userRouter = createTRPCRouter({
return memberResult?.user;
}),
getServerMetrics: withPermission("monitoring", "read").query(
async ({ ctx }) => {
getServerMetrics: withPermission("monitoring", "read")
.meta({
openapi: {
summary: "Get server metrics user",
description: "Retrieve the user record associated with server metrics access for the current organization membership.",
},
})
.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
eq(member.userId, ctx.user.id),
@@ -198,6 +252,12 @@ export const userRouter = createTRPCRouter({
},
),
update: protectedProcedure
.meta({
openapi: {
summary: "Update current user",
description: "Update the current user's profile. If changing the password, the current password must be provided and verified. Logs an audit event on success.",
},
})
.input(apiUpdateUser)
.mutation(async ({ input, ctx }) => {
if (input.password || input.currentPassword) {
@@ -248,12 +308,24 @@ export const userRouter = createTRPCRouter({
}
}),
getUserByToken: publicProcedure
.meta({
openapi: {
summary: "Get user by token",
description: "Look up a user by their authentication token. This is a public endpoint that does not require an active session.",
},
})
.input(apiFindOneToken)
.query(async ({ input }) => {
return await getUserByToken(input.token);
}),
getMetricsToken: withPermission("monitoring", "read").query(
async ({ ctx }) => {
getMetricsToken: withPermission("monitoring", "read")
.meta({
openapi: {
summary: "Get metrics token and configuration",
description: "Retrieve the server IP, paid features flag, and monitoring configuration needed for metrics collection.",
},
})
.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const settings = await getWebServerSettings();
return {
@@ -264,6 +336,12 @@ export const userRouter = createTRPCRouter({
},
),
remove: protectedProcedure
.meta({
openapi: {
summary: "Remove a user",
description: "Delete a user from the organization. Only owners and admins can remove users; owners cannot be removed, and admins cannot remove themselves or other admins. Disabled on cloud.",
},
})
.input(
z.object({
userId: z.string(),
@@ -333,6 +411,12 @@ export const userRouter = createTRPCRouter({
return result;
}),
assignPermissions: withPermission("member", "update")
.meta({
openapi: {
summary: "Assign member permissions",
description: "Update permissions for a specific member in the organization. Only the organization owner can assign permissions. Git provider and server access restrictions require a valid license.",
},
})
.input(apiAssignPermissions)
.mutation(async ({ input, ctx }) => {
try {
@@ -383,7 +467,14 @@ export const userRouter = createTRPCRouter({
throw error;
}
}),
getInvitations: protectedProcedure.query(async ({ ctx }) => {
getInvitations: protectedProcedure
.meta({
openapi: {
summary: "Get pending invitations for current user",
description: "Retrieve all pending organization invitations for the current user's email that have not yet expired.",
},
})
.query(async ({ ctx }) => {
return await db.query.invitation.findMany({
where: and(
eq(invitation.email, ctx.user.email),
@@ -397,6 +488,12 @@ export const userRouter = createTRPCRouter({
}),
getContainerMetrics: withPermission("monitoring", "read")
.meta({
openapi: {
summary: "Get container metrics",
description: "Fetch monitoring metrics for a specific container by querying the metrics endpoint. Requires an application name, metrics URL, and authentication token.",
},
})
.input(
z.object({
url: z.string(),
@@ -455,11 +552,24 @@ export const userRouter = createTRPCRouter({
}
}),
generateToken: protectedProcedure.mutation(async () => {
generateToken: protectedProcedure
.meta({
openapi: {
summary: "Generate authentication token",
description: "Generate a new authentication token for the current user.",
},
})
.mutation(async () => {
return "token";
}),
deleteApiKey: protectedProcedure
.meta({
openapi: {
summary: "Delete an API key",
description: "Delete an API key by ID. Only the owner of the API key can delete it. Logs an audit event on success.",
},
})
.input(
z.object({
apiKeyId: z.string(),
@@ -499,6 +609,12 @@ export const userRouter = createTRPCRouter({
}),
createApiKey: protectedProcedure
.meta({
openapi: {
summary: "Create an API key",
description: "Create a new API key for the current user, scoped to a specific organization. Supports optional rate limiting and request limiting configuration.",
},
})
.input(apiCreateApiKey)
.mutation(async ({ input, ctx }) => {
// Verify user is a member of the organization specified in metadata
@@ -529,6 +645,12 @@ export const userRouter = createTRPCRouter({
}),
checkUserOrganizations: protectedProcedure
.meta({
openapi: {
summary: "Check user organization count",
description: "Return the number of organizations a user belongs to. Users can check their own count; admins and owners can check counts for members in the active organization.",
},
})
.input(
z.object({
userId: z.string(),
@@ -570,6 +692,12 @@ export const userRouter = createTRPCRouter({
return organizations.length;
}),
createUserWithCredentials: withPermission("member", "create")
.meta({
openapi: {
summary: "Create user with credentials",
description: "Create a new user with email and password and add them to the active organization with the specified role. Only available in self-hosted mode.",
},
})
.input(
z.object({
email: z.string().email(),
@@ -601,6 +729,12 @@ export const userRouter = createTRPCRouter({
});
}),
sendInvitation: withPermission("member", "create")
.meta({
openapi: {
summary: "Send invitation email",
description: "Send an invitation email to a pending invitee using a configured email or Resend notification provider. Returns the generated invite link. Disabled on cloud.",
},
})
.input(
z.object({
invitationId: z.string().min(1),
@@ -676,7 +810,14 @@ export const userRouter = createTRPCRouter({
return inviteLink;
}),
getBookmarkedTemplates: protectedProcedure.query(async ({ ctx }) => {
getBookmarkedTemplates: protectedProcedure
.meta({
openapi: {
summary: "Get bookmarked templates",
description: "Retrieve the list of template IDs that the current user has bookmarked.",
},
})
.query(async ({ ctx }) => {
const result = await db.query.user.findFirst({
where: eq(user.id, ctx.user.id),
columns: { bookmarkedTemplates: true },
@@ -686,6 +827,12 @@ export const userRouter = createTRPCRouter({
}),
toggleTemplateBookmark: protectedProcedure
.meta({
openapi: {
summary: "Toggle template bookmark",
description: "Add or remove a template from the current user's bookmarks. Returns whether the template is now bookmarked.",
},
})
.input(
z.object({
templateId: z.string().min(1),

View File

@@ -30,6 +30,12 @@ import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const volumeBackupsRouter = createTRPCRouter({
list: protectedProcedure
.meta({
openapi: {
summary: "List volume backups",
description: "Returns all volume backup configurations for a given service, including related service details.",
},
})
.input(
z.object({
id: z.string().min(1),
@@ -65,6 +71,12 @@ export const volumeBackupsRouter = createTRPCRouter({
});
}),
create: protectedProcedure
.meta({
openapi: {
summary: "Create a volume backup",
description: "Creates a new volume backup configuration for a service. If enabled, automatically schedules the backup using the provided cron expression.",
},
})
.input(createVolumeBackupSchema)
.mutation(async ({ input, ctx }) => {
const serviceId =
@@ -102,6 +114,12 @@ export const volumeBackupsRouter = createTRPCRouter({
return newVolumeBackup;
}),
one: protectedProcedure
.meta({
openapi: {
summary: "Get a volume backup",
description: "Returns the details of a specific volume backup configuration by its ID.",
},
})
.input(
z.object({
volumeBackupId: z.string().min(1),
@@ -126,6 +144,12 @@ export const volumeBackupsRouter = createTRPCRouter({
return vb;
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a volume backup",
description: "Permanently removes a volume backup configuration by its ID.",
},
})
.input(
z.object({
volumeBackupId: z.string().min(1),
@@ -156,6 +180,12 @@ export const volumeBackupsRouter = createTRPCRouter({
return result;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a volume backup",
description: "Updates an existing volume backup configuration. Reschedules or removes the backup job depending on the enabled state.",
},
})
.input(updateVolumeBackupSchema)
.mutation(async ({ input, ctx }) => {
const existingVb = await findVolumeBackupById(input.volumeBackupId);
@@ -216,6 +246,12 @@ export const volumeBackupsRouter = createTRPCRouter({
}),
runManually: protectedProcedure
.meta({
openapi: {
summary: "Run a volume backup manually",
description: "Immediately executes a volume backup outside of its normal cron schedule.",
},
})
.input(z.object({ volumeBackupId: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const vb = await findVolumeBackupById(input.volumeBackupId);

View File

@@ -13,7 +13,12 @@ import { hasValidLicense } from "@dokploy/server/index";
import type { statements } from "@dokploy/server/lib/access-control";
import { validateRequest } from "@dokploy/server/lib/auth";
import { checkPermission } from "@dokploy/server/services/permission";
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
import type { OpenApiMeta as _OpenApiMeta } from "@dokploy/trpc-openapi";
// method and path are auto-generated by @dokploy/trpc-openapi, make them optional
type OpenApiMeta = {
openapi?: Partial<NonNullable<_OpenApiMeta["openapi"]>>;
};
import { initTRPC, TRPCError } from "@trpc/server";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import type { Session, User } from "better-auth";

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,8 @@ 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,15 @@ 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 +161,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 +205,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 +248,15 @@ 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(

54248
openapi.json

File diff suppressed because it is too large Load Diff

View File

@@ -225,6 +225,13 @@ export const apiUpdateCompose = createSchema
})
.omit({ serverId: true });
export const apiSaveEnvironmentVariablesCompose = createSchema
.pick({
composeId: true,
env: true,
})
.required();
export const apiRandomizeCompose = createSchema
.pick({
composeId: true,

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

@@ -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

@@ -251,15 +251,22 @@ export const deployCompose = async ({
} else {
await execAsync(commandWithLog);
}
command = "set -e;";
if (compose.sourceType !== "raw") {
command = "set -e;";
command += await generateApplyPatchesCommand({
id: compose.composeId,
type: "compose",
serverId: compose.serverId,
});
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
}
command = "set -e;";
command += await getBuildComposeCommand(entity);
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
@@ -357,6 +364,23 @@ export const rebuildCompose = async ({
} else {
await execAsync(commandWithLog);
}
if (compose.sourceType !== "raw") {
command = "set -e;";
command += await generateApplyPatchesCommand({
id: compose.composeId,
type: "compose",
serverId: compose.serverId,
});
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
}
command = "set -e;";
command += await getBuildComposeCommand(compose);
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
@@ -416,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,6 +354,69 @@ export const getContainersByAppLabel = async (
return [];
};
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 (!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 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}`;
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) => {
try {
const { stdout, stderr } = await execAsync(

View File

@@ -0,0 +1,429 @@
import { dynamicTool } from "ai";
import { z } from "zod";
import type { ChatContext } from "./chat-tools";
interface OpenApiSpec {
paths: Record<
string,
Record<
string,
{
operationId?: string;
summary?: string;
description?: string;
tags?: string[];
deprecated?: boolean;
parameters?: {
name: string;
in: string;
required?: boolean;
schema?: { type?: string };
}[];
requestBody?: {
content: Record<
string,
{ schema: { properties?: Record<string, any>; required?: string[] } }
>;
};
}
>
>;
}
interface ToolConfig {
baseUrl: string;
cookie: string;
}
interface EndpointInfo {
method: string;
path: string;
operationId: string;
}
/**
* Semantic hints for properties that the OpenAPI spec doesn't describe well.
* These are appended to parameter names in the catalog so the model knows what values to use.
*/
export const PROPERTY_HINTS: Record<string, string> = {
composePath: "path to the directory containing docker-compose.yml in the repo, e.g. '.' or 'docker/'",
composeFile: "raw YAML content of the docker-compose.yml file",
env: "environment variables as a single string, one per line in KEY=VALUE format",
dockerImage: "Docker image name with optional tag, e.g. 'nginx:alpine' or 'postgres:16'",
command: "Docker CMD override, e.g. 'npm start' or 'python app.py'",
args: "Docker command arguments as an array of strings",
buildPath: "path to the build context directory in the repo, e.g. '.' or './app'",
publishDirectory: "output directory for static builds, e.g. 'dist' or '.next'",
dockerfile: "path to the Dockerfile relative to buildPath, e.g. 'Dockerfile' or 'docker/Dockerfile.prod'",
repository: "Git repository name, e.g. 'my-app' (not the full URL)",
branch: "Git branch name, e.g. 'main' or 'develop'",
customGitUrl: "full Git clone URL, e.g. 'https://github.com/user/repo.git'",
databasePassword: "password string for the database",
externalPort: "port number exposed to the host, e.g. 5432",
host: "domain hostname, e.g. 'myapp.example.com'",
appName: "unique internal service name (auto-generated, alphanumeric with dots/hyphens)",
watchPaths: "array of file paths that trigger auto-deploy on change, e.g. ['src/**', 'package.json']",
suffix: "custom suffix appended to the service name",
repoPath: "path within the cloned repository to browse, use '.' for root directory",
filePath: "path to a specific file in the repository, e.g. 'docker-compose.yml' or 'src/index.ts'",
tail: "number of log lines to return from the end, e.g. 100",
since: "time duration for log filtering, e.g. '1h' or '30m'",
search: "text to search for in logs",
};
const EXCLUDED_TAGS = new Set([
"notification",
"sso",
"stripe",
"auditLog",
"ai",
"customRole",
"whitelabeling",
]);
/** Minimal shared tags — only project/environment for navigation */
const SHARED_TAGS = ["project", "environment"];
/** Tags allowed per context type (on top of SHARED_TAGS) */
const CONTEXT_TAGS: Record<ChatContext["type"], string[]> = {
application: [
"application",
"deployment",
"domain",
"docker",
"mounts",
"port",
"security",
"redirects",
"registry",
"sshKey",
"backup",
"volumeBackups",
"rollback",
"schedule",
"patch",
"previewDeployment",
"bitbucket",
"gitea",
"github",
"gitlab",
"gitProvider",
"destination",
"tag",
],
compose: [
"compose",
"deployment",
"domain",
"docker",
"backup",
"patch",
"sshKey",
"bitbucket",
"gitea",
"github",
"gitlab",
"gitProvider",
"tag",
],
postgres: ["postgres", "backup", "docker", "destination"],
mysql: ["mysql", "backup", "docker", "destination"],
redis: ["redis", "docker"],
mongo: ["mongo", "backup", "docker", "destination"],
mariadb: ["mariadb", "backup", "docker", "destination"],
libsql: ["libsql", "docker"],
project: [
"application",
"compose",
"postgres",
"mysql",
"redis",
"mongo",
"mariadb",
"libsql",
"domain",
"deployment",
"docker",
"tag",
],
server: [
"server",
"docker",
"cluster",
"swarm",
"certificates",
"registry",
"settings",
],
general: [], // empty = allow all non-excluded tags
};
/**
* Get the set of allowed tags for a given context type.
* Returns null for "general" context (no filtering, allow all).
*/
function getAllowedTags(contextType: ChatContext["type"]): Set<string> | null {
if (contextType === "general") return null;
const contextSpecific = CONTEXT_TAGS[contextType];
return new Set([...SHARED_TAGS, ...contextSpecific]);
}
/**
* Extract enum values from a JSON Schema property (handles anyOf wrappers).
*/
function extractEnum(prop: any): string[] | null {
if (prop?.enum) return prop.enum;
if (Array.isArray(prop?.anyOf)) {
for (const variant of prop.anyOf) {
if (variant?.enum) return variant.enum;
}
}
return null;
}
/** Human-readable description for each tag group in the catalog */
const TAG_DESCRIPTIONS: Record<string, string> = {
application: "Manage application services — create, update, deploy, start, stop, and configure applications",
compose: "Manage Docker Compose/Stack services — create, update, deploy, and configure compose files",
postgres: "Manage PostgreSQL database services",
mysql: "Manage MySQL database services",
redis: "Manage Redis database services",
mongo: "Manage MongoDB database services",
mariadb: "Manage MariaDB database services",
libsql: "Manage LibSQL database services",
deployment: "View deployment history, build logs, and manage deployment lifecycle",
domain: "Manage domains, SSL certificates, and routing for services",
docker: "Interact with Docker containers — inspect, restart, remove, and view logs",
backup: "Create and manage database backups, run manual backups, and restore from backups",
patch: "Browse and modify source code files in a service's cloned repository — read directories, read files, and create file patches",
mounts: "Manage persistent volume mounts for services",
port: "Manage exposed port mappings for services",
security: "Manage HTTP basic auth security rules for services",
redirects: "Manage HTTP redirect rules for domains",
registry: "Manage Docker registries for pulling private images",
sshKey: "Manage SSH keys for Git repository access",
rollback: "Rollback a service to a previous deployment",
schedule: "Create and manage scheduled tasks (cron jobs) for services",
previewDeployment: "Manage preview deployments for pull requests",
volumeBackups: "Create and manage volume-level backups and restores",
project: "Manage projects — create, update, delete, and list projects",
environment: "Manage environments within projects — create, duplicate, and configure",
server: "Manage servers — configure, monitor, and connect remote servers",
settings: "View and update global Dokploy settings",
destination: "Manage S3/storage destinations for backups",
tag: "Manage tags for organizing and labeling services",
cluster: "Manage Docker Swarm cluster nodes",
swarm: "Manage Docker Swarm settings and configuration",
certificates: "Manage SSL/TLS certificates",
gitProvider: "Manage Git provider integrations",
github: "Manage GitHub provider connections and repositories",
gitlab: "Manage GitLab provider connections and repositories",
bitbucket: "Manage Bitbucket provider connections and repositories",
gitea: "Manage Gitea provider connections and repositories",
user: "Manage user accounts and permissions",
};
export interface CatalogResult {
catalog: string;
count: number;
operationIds: Set<string>;
}
export function buildEndpointCatalog(
spec: OpenApiSpec,
contextType: ChatContext["type"] = "general",
relevantOperationIds?: Set<string>,
): CatalogResult {
const operationIds = new Set<string>();
const allowedTags = getAllowedTags(contextType);
const groups = new Map<string, string[]>();
for (const methods of Object.values(spec.paths)) {
for (const op of Object.values(methods)) {
if (!op.operationId || op.deprecated) continue;
if (op.tags?.some((t) => EXCLUDED_TAGS.has(t))) continue;
if (allowedTags && !op.tags?.some((t) => allowedTags.has(t))) continue;
if (relevantOperationIds && !relevantOperationIds.has(op.operationId)) continue;
operationIds.add(op.operationId);
const requiredParams: string[] = [];
const optionalParams: string[] = [];
if (op.parameters) {
for (const p of op.parameters) {
if (p.in === "header") continue;
if (p.required) {
requiredParams.push(`${p.name}*`);
} else {
optionalParams.push(`${p.name}?`);
}
}
}
if (op.requestBody?.content?.["application/json"]?.schema) {
const schema = op.requestBody.content["application/json"].schema;
const requiredSet = new Set(schema.required ?? []);
if (schema.properties) {
for (const [key, prop] of Object.entries(
schema.properties as Record<string, any>,
)) {
const enumVals = extractEnum(prop);
const hint = PROPERTY_HINTS[key];
const suffix = enumVals
? `[${enumVals.join("|")}]`
: hint
? `(${hint})`
: "";
if (requiredSet.has(key)) {
requiredParams.push(`${key}*${suffix}`);
} else {
optionalParams.push(`${key}?${suffix}`);
}
}
}
}
const allParams = [...requiredParams, ...optionalParams];
const paramStr =
allParams.length > 0 ? `(${allParams.join(", ")})` : "";
const summary = op.summary ? `${op.summary}` : "";
const desc = op.description ? `\n ${op.description}` : "";
const line = `${op.operationId}${paramStr}${summary}${desc}`;
const tag = op.tags?.[0] ?? "other";
if (!groups.has(tag)) groups.set(tag, []);
groups.get(tag)!.push(line);
}
}
// Order sections: context-specific tags first (in CONTEXT_TAGS order), then shared, then rest
const contextOrder = CONTEXT_TAGS[contextType];
const sharedOrder = SHARED_TAGS;
const orderedTags: string[] = [];
const seen = new Set<string>();
for (const t of contextOrder) {
if (groups.has(t) && !seen.has(t)) { orderedTags.push(t); seen.add(t); }
}
for (const t of sharedOrder) {
if (groups.has(t) && !seen.has(t)) { orderedTags.push(t); seen.add(t); }
}
for (const t of groups.keys()) {
if (!seen.has(t)) { orderedTags.push(t); seen.add(t); }
}
const sections: string[] = [];
for (const tag of orderedTags) {
const lines = groups.get(tag)!;
const tagDesc = TAG_DESCRIPTIONS[tag];
const header = tagDesc ? `## ${tag}${tagDesc}` : `## ${tag}`;
sections.push(`${header}\n${lines.join("\n")}`);
}
return {
catalog: sections.join("\n\n"),
count: operationIds.size,
operationIds,
};
}
/**
* Build a lookup map from operationId to endpoint info for execution.
*/
function buildEndpointMap(
spec: OpenApiSpec,
): Map<string, EndpointInfo> {
const map = new Map<string, EndpointInfo>();
for (const [path, methods] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(methods)) {
if (!op.operationId) continue;
map.set(op.operationId, { method, path, operationId: op.operationId });
}
}
return map;
}
/**
* Creates a single "call_api" tool that only allows endpoints present in allowedOperationIds.
*/
export function createApiTool(
spec: OpenApiSpec,
config: ToolConfig,
allowedOperationIds?: Set<string>,
maxResponseSize = 4000,
) {
const endpointMap = buildEndpointMap(spec);
return {
call_api: dynamicTool({
description:
"Call a Dokploy API endpoint. Use the operationId from the endpoint catalog and pass the required parameters.",
inputSchema: z.object({
operationId: z
.string()
.describe("The operationId from the endpoint catalog"),
params: z
.record(z.string(), z.any())
.optional()
.describe("Parameters for the endpoint (* = required)"),
}),
execute: async (rawInput: unknown) => {
const { operationId, params } = rawInput as {
operationId: string;
params?: Record<string, unknown>;
};
if (allowedOperationIds && !allowedOperationIds.has(operationId)) {
return `Error: "${operationId}" is not available in the current context. Only use operationIds from the ENDPOINT CATALOG.`;
}
const endpoint = endpointMap.get(operationId);
if (!endpoint) {
return `Error: Unknown endpoint "${operationId}". Check the endpoint catalog for valid operationIds.`;
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
Cookie: config.cookie,
};
let url = `${config.baseUrl}${endpoint.path}`;
if (endpoint.method === "get" && params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
}
const qs = searchParams.toString();
if (qs) url += `?${qs}`;
}
try {
const response = await fetch(url, {
method: endpoint.method.toUpperCase(),
headers,
...(endpoint.method !== "get" && params
? { body: JSON.stringify(params) }
: {}),
});
if (!response.ok) {
const errorText = await response.text();
return `API error (${response.status}): ${errorText.slice(0, 500)}\n\nHint: Check the ENDPOINT CATALOG for required parameters (*). You called "${operationId}" with params: ${JSON.stringify(params ?? {})}`;
}
const json = JSON.stringify(await response.json(), null, 2);
if (json.length > maxResponseSize) {
return `${json.slice(0, maxResponseSize)}\n\n... [Truncated — ${json.length} chars total]`;
}
return json;
} catch (error) {
return `Error: ${error instanceof Error ? error.message : "Unknown error"}`;
}
},
}),
};
}

View File

@@ -0,0 +1,319 @@
import { readFile } from "node:fs/promises";
import { dynamicTool } from "ai";
import { z } from "zod";
import { findDeploymentById } from "../../services/deployment";
export type ServiceType =
| "application"
| "compose"
| "postgres"
| "mysql"
| "redis"
| "mongo"
| "mariadb"
| "libsql";
export interface ChatContext {
type: ServiceType | "project" | "server" | "general";
id: string;
projectId?: string;
environmentId?: string;
serverId?: string;
}
interface ToolConfig {
baseUrl: string;
cookie: string;
}
async function callApi(
config: ToolConfig,
method: "GET" | "POST",
path: string,
params?: Record<string, unknown>,
) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Cookie: config.cookie,
};
let url = `${config.baseUrl}${path}`;
if (method === "GET" && params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
}
const qs = searchParams.toString();
if (qs) url += `?${qs}`;
}
const response = await fetch(url, {
method,
headers,
...(method === "POST" && params ? { body: JSON.stringify(params) } : {}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API error (${response.status}): ${errorText.slice(0, 500)}`);
}
return response.json();
}
function makeTool(
description: string,
inputSchema: z.ZodObject<z.ZodRawShape>,
executeFn: (input: Record<string, unknown>) => Promise<unknown>,
) {
return dynamicTool({
description,
inputSchema,
execute: async (rawInput: unknown) => {
try {
const input = (rawInput ?? {}) as Record<string, unknown>;
const result = await executeFn(input);
const json = JSON.stringify(result, null, 2);
// Truncate very large responses
if (json.length > 15000) {
return `${json.slice(0, 15000)}\n\n... [Truncated — ${json.length} chars total]`;
}
return json;
} catch (error) {
return `Error: ${error instanceof Error ? error.message : "Unknown error"}`;
}
},
});
}
// ─── READ TOOLS ──────────────────────────────────────────────
function readTools(context: ChatContext, config: ToolConfig) {
const tools: Record<string, ReturnType<typeof dynamicTool>> = {};
if (context.type === "application") {
tools["get-application-info"] = makeTool(
"Get the full configuration of the current application: name, status, build type, source, env vars, resource limits, and more. Call this first to understand the app state.",
z.object({}),
() => callApi(config, "GET", "/application.one", { applicationId: context.id }),
);
tools["list-deployments"] = makeTool(
"List the 10 most recent deployments for this application. Each deployment has a status (done/error/running), title, error message, and timestamps. Use this to find failed builds.",
z.object({}),
() => callApi(config, "GET", "/deployment.allByType", { id: context.id, type: "application" }),
);
tools["list-domains"] = makeTool(
"List all domains configured for this application.",
z.object({}),
() => callApi(config, "GET", "/domain.byApplicationId", { applicationId: context.id }),
);
tools["get-containers"] = makeTool(
"List running Docker containers for this application. Shows container state, status, and names.",
z.object({}),
async () => {
const app = await callApi(config, "GET", "/application.one", { applicationId: context.id });
return callApi(config, "GET", "/docker.getContainersByAppNameMatch", {
appName: (app as { appName: string }).appName,
});
},
);
}
if (context.type === "compose") {
tools["get-compose-info"] = makeTool(
"Get the full configuration of the current compose service: name, status, compose file content, env vars, and more.",
z.object({}),
() => callApi(config, "GET", "/compose.one", { composeId: context.id }),
);
tools["list-deployments"] = makeTool(
"List the 10 most recent deployments for this compose service.",
z.object({}),
() => callApi(config, "GET", "/deployment.allByType", { id: context.id, type: "compose" }),
);
tools["list-domains"] = makeTool(
"List all domains configured for this compose service.",
z.object({}),
() => callApi(config, "GET", "/domain.byComposeId", { composeId: context.id }),
);
}
if (context.type === "project") {
tools["get-project-info"] = makeTool(
"Get the full project details including ALL environments and ALL services (applications, compose, databases). Use this to count services, see what's deployed, and find failing services.",
z.object({}),
() => callApi(config, "GET", "/project.one", { projectId: context.id }),
);
}
if (context.type === "general") {
tools["list-projects"] = makeTool(
"List all projects in the organization.",
z.object({}),
() => callApi(config, "GET", "/project.all"),
);
}
// Available in both application and compose contexts
if (context.type === "application" || context.type === "compose") {
tools["read-deployment-logs"] = dynamicTool({
description:
"Read the build/deployment logs for a specific deployment. ALWAYS call list-deployments first to find the deploymentId. This reads the actual log file content to diagnose build failures.",
inputSchema: z.object({
deploymentId: z.string().describe("The deployment ID from list-deployments"),
}),
execute: async (rawInput: unknown) => {
const { deploymentId } = rawInput as { deploymentId: string };
try {
const deployment = await findDeploymentById(deploymentId);
const content = await readFile(deployment.logPath, "utf-8");
const lines = content.split("\n");
const last200 = lines.slice(-200).join("\n");
return `Deployment status: ${deployment.status}\nError message: ${deployment.errorMessage || "none"}\n\nLast 200 lines of build log:\n${last200}`;
} catch {
return "Could not read deployment logs — the log file may not exist.";
}
},
});
tools["read-runtime-logs"] = makeTool(
"Read the runtime/container logs (stdout/stderr) of this application. Shows the last N lines of the running application output. Use this to diagnose runtime errors, crashes, or check if the app is working.",
z.object({
tail: z.number().optional().describe("Number of lines to read (default 200, max 500)"),
}),
(input) => {
const tail = Math.min((input.tail as number) || 200, 500);
const endpoint = context.type === "compose" ? "/compose.readLogs" : "/application.readLogs";
const idKey = context.type === "compose" ? "composeId" : "applicationId";
return callApi(config, "GET", endpoint, {
[idKey]: context.id,
tail,
since: "all",
});
},
);
}
return tools;
}
// ─── WRITE TOOLS ─────────────────────────────────────────────
function writeTools(context: ChatContext, config: ToolConfig) {
const tools: Record<string, ReturnType<typeof dynamicTool>> = {};
if (context.type === "application") {
tools["update-env-vars"] = makeTool(
"Update the environment variables for this application. Pass the FULL env string (KEY=VALUE format, one per line). This REPLACES all existing env vars, so include the ones you want to keep.",
z.object({
env: z.string().describe("Full environment variables, one KEY=VALUE per line"),
}),
(input) =>
callApi(config, "POST", "/application.saveEnvironment", {
applicationId: context.id,
env: input.env,
}),
);
tools["deploy-application"] = makeTool(
"Trigger a new deployment/build for this application. The build will run in the background.",
z.object({}),
() =>
callApi(config, "POST", "/application.deploy", {
applicationId: context.id,
title: "AI-triggered deployment",
description: "Deployed via AI Assistant",
}),
);
tools["redeploy-application"] = makeTool(
"Redeploy the application using the existing build (no new build). Faster than deploy.",
z.object({}),
() =>
callApi(config, "POST", "/application.redeploy", {
applicationId: context.id,
title: "AI-triggered redeployment",
description: "Redeployed via AI Assistant",
}),
);
tools["stop-application"] = makeTool(
"Stop the application. This will stop all containers.",
z.object({}),
() => callApi(config, "POST", "/application.stop", { applicationId: context.id }),
);
tools["start-application"] = makeTool(
"Start a stopped application.",
z.object({}),
() => callApi(config, "POST", "/application.start", { applicationId: context.id }),
);
tools["restart-container"] = makeTool(
"Restart a specific Docker container. Use get-containers first to find the container ID.",
z.object({
containerId: z.string().describe("The container ID from get-containers"),
}),
(input) => callApi(config, "POST", "/docker.restartContainer", { containerId: input.containerId }),
);
}
if (context.type === "compose") {
tools["update-compose-env"] = makeTool(
"Update the environment variables for this compose service. Pass the FULL env string.",
z.object({
env: z.string().describe("Full environment variables, one KEY=VALUE per line"),
}),
(input) =>
callApi(config, "POST", "/compose.update", {
composeId: context.id,
env: input.env,
}),
);
tools["deploy-compose"] = makeTool(
"Trigger a new deployment for this compose service.",
z.object({}),
() =>
callApi(config, "POST", "/compose.deploy", {
composeId: context.id,
title: "AI-triggered deployment",
description: "Deployed via AI Assistant",
}),
);
tools["stop-compose"] = makeTool(
"Stop the compose service.",
z.object({}),
() => callApi(config, "POST", "/compose.stop", { composeId: context.id }),
);
tools["start-compose"] = makeTool(
"Start a stopped compose service.",
z.object({}),
() => callApi(config, "POST", "/compose.start", { composeId: context.id }),
);
}
return tools;
}
// ─── PUBLIC API ──────────────────────────────────────────────
export function getReadTools(context: ChatContext, config: ToolConfig) {
return readTools(context, config);
}
export function getAllTools(context: ChatContext, config: ToolConfig) {
return {
...readTools(context, config),
...writeTools(context, config),
};
}

View File

@@ -0,0 +1,317 @@
import { dynamicTool } from "ai";
import { z } from "zod";
/**
* Converts an OpenAPI spec into AI SDK tool definitions automatically.
*
* Each endpoint becomes a tool that the agent can call. The tool name
* is the operationId, the description comes from the endpoint's
* summary/description, and the input schema is derived from the
* request body or query parameters.
*/
interface OpenApiSpec {
paths: Record<string, Record<string, OpenApiOperation>>;
}
interface OpenApiOperation {
operationId: string;
summary?: string;
description?: string;
tags?: string[];
deprecated?: boolean;
parameters?: OpenApiParameter[];
requestBody?: {
required?: boolean;
content: Record<
string,
{
schema: JsonSchema;
}
>;
};
}
interface OpenApiParameter {
name: string;
in: "query" | "path" | "header";
required?: boolean;
schema: JsonSchema;
description?: string;
}
interface JsonSchema {
type?: string;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
enum?: unknown[];
description?: string;
default?: unknown;
nullable?: boolean;
anyOf?: JsonSchema[];
oneOf?: JsonSchema[];
allOf?: JsonSchema[];
format?: string;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
}
interface ToolConfig {
baseUrl: string;
cookie: string;
}
interface GenerateToolsOptions {
/** Only include tools whose tag matches one of these */
tags?: string[];
/** Only include these specific operationIds */
operationIds?: string[];
/** Exclude these operationIds */
exclude?: string[];
/** Max response size in chars before truncating (default: 15000) */
maxResponseSize?: number;
}
// ─── JSON Schema → Zod conversion ──────────────────────────────
function jsonSchemaToZod(schema: JsonSchema): z.ZodTypeAny {
if (!schema || !schema.type) {
// anyOf / oneOf / allOf — just accept anything
if (schema?.anyOf || schema?.oneOf || schema?.allOf) {
return z.any();
}
return z.any();
}
switch (schema.type) {
case "string": {
let s = z.string();
if (schema.enum) {
return z.enum(schema.enum as [string, ...string[]]);
}
if (schema.minLength) s = s.min(schema.minLength);
if (schema.maxLength) s = s.max(schema.maxLength);
if (schema.description) s = s.describe(schema.description);
return s;
}
case "number":
case "integer": {
let n = z.number();
if (schema.minimum !== undefined) n = n.min(schema.minimum);
if (schema.maximum !== undefined) n = n.max(schema.maximum);
if (schema.description) n = n.describe(schema.description);
return n;
}
case "boolean":
return z.boolean();
case "array": {
const itemSchema = schema.items
? jsonSchemaToZod(schema.items)
: z.any();
return z.array(itemSchema);
}
case "object": {
if (!schema.properties) {
return z.object({});
}
const shape: Record<string, z.ZodTypeAny> = {};
const required = new Set(schema.required ?? []);
for (const [key, propSchema] of Object.entries(schema.properties)) {
const zodProp = jsonSchemaToZod(propSchema);
shape[key] = required.has(key) ? zodProp : zodProp.optional();
}
return z.object(shape);
}
default:
return z.any();
}
}
function buildInputSchema(
operation: OpenApiOperation,
): z.ZodObject<z.ZodRawShape> {
const shape: Record<string, z.ZodTypeAny> = {};
// Query/path parameters → flat keys
if (operation.parameters) {
for (const param of operation.parameters) {
if (param.in === "header") continue;
const zodParam = jsonSchemaToZod(param.schema);
const described = param.description
? zodParam.describe(param.description)
: zodParam;
shape[param.name] = param.required ? described : described.optional();
}
}
// Request body → merge properties into the same object
if (operation.requestBody) {
const content = operation.requestBody.content;
const jsonContent = content["application/json"];
if (jsonContent?.schema) {
const bodySchema = jsonContent.schema;
if (bodySchema.properties) {
const required = new Set(bodySchema.required ?? []);
for (const [key, propSchema] of Object.entries(
bodySchema.properties,
)) {
const zodProp = jsonSchemaToZod(propSchema);
shape[key] = required.has(key) ? zodProp : zodProp.optional();
}
}
}
}
return z.object(shape);
}
// ─── API caller ─────────────────────────────────────────────────
async function callApi(
config: ToolConfig,
method: string,
path: string,
params: Record<string, unknown> | undefined,
maxResponseSize: number,
): Promise<string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Cookie: config.cookie,
};
let url = `${config.baseUrl}${path}`;
if (method === "get" && params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
}
const qs = searchParams.toString();
if (qs) url += `?${qs}`;
}
const response = await fetch(url, {
method: method.toUpperCase(),
headers,
...(method !== "get" && params
? { body: JSON.stringify(params) }
: {}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`API error (${response.status}): ${errorText.slice(0, 500)}`,
);
}
const json = JSON.stringify(await response.json(), null, 2);
if (json.length > maxResponseSize) {
return `${json.slice(0, maxResponseSize)}\n\n... [Truncated — ${json.length} chars total]`;
}
return json;
}
// ─── Main conversion ────────────────────────────────────────────
export function openApiToTools(
spec: OpenApiSpec,
config: ToolConfig,
options: GenerateToolsOptions = {},
) {
const {
tags,
operationIds,
exclude,
maxResponseSize = 15000,
} = options;
const tagSet = tags ? new Set(tags) : null;
const idSet = operationIds ? new Set(operationIds) : null;
const excludeSet = exclude ? new Set(exclude) : null;
const tools: Record<string, ReturnType<typeof dynamicTool>> = {};
for (const [path, methods] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(methods)) {
const opId = operation.operationId;
if (!opId) continue;
if (operation.deprecated) continue;
// Filtering
if (excludeSet?.has(opId)) continue;
if (idSet && !idSet.has(opId)) continue;
if (tagSet && !operation.tags?.some((t) => tagSet.has(t))) continue;
const description = [operation.summary, operation.description]
.filter(Boolean)
.join(". ");
const inputSchema = buildInputSchema(operation);
const isWriteAction = method !== "get";
tools[opId] = dynamicTool({
description: description || `Call ${method.toUpperCase()} ${path}`,
inputSchema,
needsApproval: isWriteAction,
execute: async (rawInput: unknown) => {
const input = (rawInput ?? {}) as Record<string, unknown>;
const params =
Object.keys(input).length > 0 ? input : undefined;
return callApi(config, method, path, params, maxResponseSize);
},
});
}
}
return tools;
}
/**
* Returns a summary of all available tools (name + description).
* Useful for debugging or for the system prompt.
*/
export function getToolsSummary(
spec: OpenApiSpec,
options: GenerateToolsOptions = {},
): { name: string; description: string; tag: string; method: string }[] {
const { tags, operationIds, exclude } = options;
const tagSet = tags ? new Set(tags) : null;
const idSet = operationIds ? new Set(operationIds) : null;
const excludeSet = exclude ? new Set(exclude) : null;
const summary: {
name: string;
description: string;
tag: string;
method: string;
}[] = [];
for (const [_path, methods] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(methods)) {
const opId = operation.operationId;
if (!opId) continue;
if (operation.deprecated) continue;
if (excludeSet?.has(opId)) continue;
if (idSet && !idSet.has(opId)) continue;
if (tagSet && !operation.tags?.some((t) => tagSet.has(t))) continue;
summary.push({
name: opId,
description:
[operation.summary, operation.description]
.filter(Boolean)
.join(". ") || "",
tag: operation.tags?.[0] ?? "default",
method: method.toUpperCase(),
});
}
}
return summary;
}

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

@@ -0,0 +1,250 @@
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { PROPERTY_HINTS } from "./api-tool";
interface EndpointEmbedding {
operationId: string;
text: string;
tags: string[];
embedding: number[];
}
const VOYAGE_MODEL = "voyage-3-lite";
const VOYAGE_API = "https://api.voyageai.com/v1/embeddings";
const BATCH_SIZE = 128;
/**
* Call Voyage AI to embed an array of texts.
*/
async function embedTexts(
texts: string[],
apiKey: string,
inputType: "document" | "query" = "document",
): Promise<number[][]> {
const results: number[][] = [];
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE);
const response = await fetch(VOYAGE_API, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: VOYAGE_MODEL,
input: batch,
input_type: inputType,
}),
});
if (!response.ok) {
throw new Error(
`Voyage API error: ${response.status} ${await response.text()}`,
);
}
const data = (await response.json()) as {
data: { embedding: number[] }[];
};
for (const item of data.data) {
results.push(item.embedding);
}
}
return results;
}
/**
* Cosine similarity between two vectors.
*/
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i]! * b[i]!;
normA += a[i]! * a[i]!;
normB += b[i]! * b[i]!;
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
// In-memory cache
let cachedEmbeddings: EndpointEmbedding[] | null = null;
/**
* Extract enum values from a JSON Schema property (handles anyOf wrappers).
*/
function extractEnum(prop: any): string[] | null {
if (prop?.enum) return prop.enum;
if (Array.isArray(prop?.anyOf)) {
for (const variant of prop.anyOf) {
if (variant?.enum) return variant.enum;
}
}
return null;
}
/**
* Build a rich text representation for an endpoint (used for embedding).
* Includes: operationId, method, path, params with enums, summary, description.
*/
function buildEndpointText(
op: any,
method: string,
path: string,
): string {
const parts: string[] = [];
// Operation identity
parts.push(`${op.operationId} [${method.toUpperCase()} ${path}]`);
// Tags
if (op.tags?.length) {
parts.push(`Tags: ${op.tags.join(", ")}`);
}
// Summary + description
if (op.summary) parts.push(op.summary);
if (op.description) parts.push(op.description);
// Parameters
const params: string[] = [];
if (op.parameters) {
for (const p of op.parameters) {
if (p.in === "header") continue;
const req = p.required ? "required" : "optional";
params.push(`${p.name} (${req})`);
}
}
if (op.requestBody?.content?.["application/json"]?.schema) {
const schema = op.requestBody.content["application/json"].schema;
const requiredSet = new Set(schema.required ?? []);
if (schema.properties) {
for (const [key, prop] of Object.entries(
schema.properties as Record<string, any>,
)) {
const req = requiredSet.has(key) ? "required" : "optional";
const enumVals = extractEnum(prop);
const hint = PROPERTY_HINTS[key];
const extra = enumVals
? ` [${enumVals.join("|")}]`
: hint
? `${hint}`
: "";
params.push(`${key} (${req})${extra}`);
}
}
}
if (params.length > 0) {
parts.push(`Parameters: ${params.join(", ")}`);
}
return parts.join(". ");
}
/**
* Generate or load embeddings for all endpoints in the OpenAPI spec.
* Embeddings are cached in .tool-embeddings.json and in memory.
*/
export async function getOrCreateEmbeddings(
spec: any,
voyageApiKey: string,
cachePath?: string,
): Promise<EndpointEmbedding[]> {
// Return from memory cache
if (cachedEmbeddings) return cachedEmbeddings;
// Try loading from file cache
const filePath =
cachePath || join(process.cwd(), ".tool-embeddings.json");
if (existsSync(filePath)) {
try {
const data = JSON.parse(readFileSync(filePath, "utf-8"));
if (Array.isArray(data) && data.length > 0 && data[0].embedding) {
cachedEmbeddings = data;
return cachedEmbeddings;
}
} catch {
// Corrupted file — regenerate
}
}
// Generate embeddings from spec
const endpoints: { operationId: string; text: string; tags: string[] }[] =
[];
for (const [path, methods] of Object.entries(spec.paths ?? {})) {
for (const [method, op] of Object.entries(methods as Record<string, any>)) {
if (!op.operationId || op.deprecated) continue;
endpoints.push({
operationId: op.operationId,
text: buildEndpointText(op, method, path),
tags: op.tags ?? [],
});
}
}
if (endpoints.length === 0) {
cachedEmbeddings = [];
return cachedEmbeddings;
}
const texts = endpoints.map((e) => e.text);
const embeddings = await embedTexts(texts, voyageApiKey, "document");
cachedEmbeddings = endpoints.map((e, i) => ({
...e,
embedding: embeddings[i]!,
}));
// Persist to file
try {
writeFileSync(filePath, JSON.stringify(cachedEmbeddings));
} catch {
// Non-critical — will regenerate next time
}
return cachedEmbeddings;
}
/**
* Retrieve the top-K most relevant endpoints for a user query,
* optionally filtered to a pre-computed set of allowed operationIds.
*/
export async function retrieveRelevantEndpoints(
query: string,
allEmbeddings: EndpointEmbedding[],
voyageApiKey: string,
options?: {
allowedOperationIds?: Set<string>;
topK?: number;
},
): Promise<string[]> {
const { allowedOperationIds, topK = 20 } = options ?? {};
// Filter to allowed operationIds (from tag filtering)
const candidates = allowedOperationIds
? allEmbeddings.filter((e) => allowedOperationIds.has(e.operationId))
: allEmbeddings;
if (candidates.length === 0) return [];
// Embed the user query
const [queryEmbedding] = await embedTexts([query], voyageApiKey, "query");
if (!queryEmbedding) return [];
// Score and rank
const scored = candidates.map((e) => ({
operationId: e.operationId,
score: cosineSimilarity(queryEmbedding, e.embedding),
}));
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, topK).map((s) => s.operationId);
}

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

@@ -240,14 +240,13 @@ export const sendBuildErrorNotifications = async ({
value: `\`\`\`${errorMessage}\`\`\``,
short: false,
},
],
actions: [
{
type: "button",
text: "View Build Details",
url: buildLink,
title: "Details",
value: `<${buildLink}|View Build Details>`,
short: false,
},
],
mrkdwn_in: ["fields"],
},
],
});

View File

@@ -256,14 +256,13 @@ export const sendBuildSuccessNotifications = async ({
value: date.toLocaleString(),
short: true,
},
],
actions: [
{
type: "button",
text: "View Build Details",
url: buildLink,
title: "Details",
value: `<${buildLink}|View Build Details>`,
short: false,
},
],
mrkdwn_in: ["fields"],
},
],
});

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

@@ -3,10 +3,12 @@ export const sendEmail = async ({
email,
subject,
text,
attachments,
}: {
email: string;
subject: string;
text: string;
attachments?: { filename: string; content: Buffer }[];
}) => {
await sendEmailNotification(
{
@@ -19,6 +21,7 @@ export const sendEmail = async ({
},
subject,
text,
attachments,
);
return true;

142
pnpm-lock.yaml generated
View File

@@ -113,12 +113,15 @@ importers:
'@ai-sdk/openai-compatible':
specifier: ^2.0.30
version: 2.0.30(zod@4.3.6)
'@ai-sdk/react':
specifier: ^3.0.156
version: 3.0.156(react@18.2.0)(zod@4.3.6)
'@better-auth/api-key':
specifier: 1.5.4
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(febde88eaf587188179e6ecc47119e50))
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(219be630f7f6fef2e235cef94eaddc25))
'@better-auth/sso':
specifier: 1.5.4
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(febde88eaf587188179e6ecc47119e50))(better-call@2.0.2(zod@4.3.6))
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(219be630f7f6fef2e235cef94eaddc25))(better-call@2.0.2(zod@4.3.6))
'@codemirror/autocomplete':
specifier: ^6.18.6
version: 6.20.0
@@ -147,8 +150,8 @@ importers:
specifier: workspace:*
version: link:../../packages/server
'@dokploy/trpc-openapi':
specifier: 0.0.18
version: 0.0.18(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)
specifier: 0.0.19
version: 0.0.19(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)
'@faker-js/faker':
specifier: ^8.4.1
version: 8.4.1
@@ -277,7 +280,7 @@ importers:
version: 5.1.1
better-auth:
specifier: 1.5.4
version: 1.5.4(febde88eaf587188179e6ecc47119e50)
version: 1.5.4(219be630f7f6fef2e235cef94eaddc25)
bl:
specifier: 6.0.11
version: 6.0.11
@@ -536,10 +539,10 @@ importers:
version: 5.9.3
vite-tsconfig-paths:
specifier: 4.3.2
version: 4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1))
version: 4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
apps/schedules:
dependencies:
@@ -627,10 +630,10 @@ importers:
version: 2.0.30(zod@4.3.6)
'@better-auth/api-key':
specifier: 1.5.4
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(334901c35c1fcda64bb596793b2e4934))
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(8933545d763d3f096150f97f9213a424))
'@better-auth/sso':
specifier: 1.5.4
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(334901c35c1fcda64bb596793b2e4934))(better-call@2.0.2(zod@4.3.6))
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(8933545d763d3f096150f97f9213a424))(better-call@2.0.2(zod@4.3.6))
'@better-auth/utils':
specifier: 0.3.1
version: 0.3.1
@@ -669,7 +672,7 @@ importers:
version: 5.1.1
better-auth:
specifier: 1.5.4
version: 1.5.4(334901c35c1fcda64bb596793b2e4934)
version: 1.5.4(8933545d763d3f096150f97f9213a424)
better-call:
specifier: 2.0.2
version: 2.0.2(zod@4.3.6)
@@ -772,7 +775,7 @@ importers:
devDependencies:
'@better-auth/cli':
specifier: 1.4.21
version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.28.11)(mongodb@7.1.0)(mysql2@3.15.3)(nanostores@1.1.1)(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))
version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.28.11)(mongodb@7.1.0)(mysql2@3.15.3)(nanostores@1.1.1)(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1))
'@types/adm-zip':
specifier: ^0.5.7
version: 0.5.7
@@ -878,6 +881,12 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.94':
resolution: {integrity: sha512-uDDwLZhCkvC89crVS3S90D5L7AcVN8WriGuYVNYgVAaVcvy3Mthy3R9ICfzG75BObhz6pm2FWnhxDfNRK+t69Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/mistral@3.0.20':
resolution: {integrity: sha512-oZcx2pE6nJ+Qj/U6HFV5mJ52jXJPBSpvki/NtIocZkI/rKxphKBaecOH1h0Y7yK3HIbBxsMqefB1pb72cAHGVg==}
engines: {node: '>=18'}
@@ -902,10 +911,22 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.23':
resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@3.0.8':
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
engines: {node: '>=18'}
'@ai-sdk/react@3.0.156':
resolution: {integrity: sha512-/6rmGxOJlCNS6wJBUNsO49aeSK740fS2wVcA3Xn8IOBRFFz3hWm6auQTMoA0nHKu4hnH6ivA6hog6Ul+1Bv4Rg==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -1291,8 +1312,8 @@ packages:
'@codemirror/view@6.39.15':
resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==}
'@dokploy/trpc-openapi@0.0.18':
resolution: {integrity: sha512-CbppvUEe8eK1fiNGQL5AH8KIRRlHk5bGPUEIyc2VBZE0un4kfUs5DXKSKsMLDomoES5ZEdrjT4nKpwYvhDha0w==}
'@dokploy/trpc-openapi@0.0.19':
resolution: {integrity: sha512-pmajIu1tIU3yqqbYdRHKFbA8gP37gO7F61PL9AFNtoyhh6gVxALXQLDabtE7dobKAhNDmIj6vV1a2vyP1Zi7/w==}
peerDependencies:
'@trpc/server': ^11.1.0
zod: ^4.3.6
@@ -4241,6 +4262,12 @@ packages:
peerDependencies:
ai: ^6.0.89
ai@6.0.154:
resolution: {integrity: sha512-HfKJKCTJsDZxqrIUDSVnBQ7DpQlx5WI4ExqtLd7Bl70epLmvkpc/HYMzU1hP9W+g9VEAcvZo4fbMqc3v5D+9gQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ai@6.0.97:
resolution: {integrity: sha512-eZIAcBymwGhBwncRH/v9pillZNMeRCDkc4BwcvwXerXd7sxjVxRis3ZNCNCpP02pVH4NLs81ljm4cElC4vbNcQ==}
engines: {node: '>=18'}
@@ -7707,6 +7734,11 @@ packages:
react: '>=16.8.0 <20'
react-dom: '>=16.8.0 <20'
swr@2.4.1:
resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
symbol-observable@1.2.0:
resolution: {integrity: sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==}
engines: {node: '>=0.10.0'}
@@ -7764,6 +7796,10 @@ packages:
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
throttleit@2.1.0:
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
engines: {node: '>=18'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -8329,6 +8365,13 @@ snapshots:
'@vercel/oidc': 3.1.0
zod: 4.3.6
'@ai-sdk/gateway@3.0.94(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.23(zod@4.3.6)
'@vercel/oidc': 3.1.0
zod: 4.3.6
'@ai-sdk/mistral@3.0.20(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
@@ -8354,10 +8397,27 @@ snapshots:
eventsource-parser: 3.0.6
zod: 4.3.6
'@ai-sdk/provider-utils@4.0.23(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 4.3.6
'@ai-sdk/provider@3.0.8':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@3.0.156(react@18.2.0)(zod@4.3.6)':
dependencies:
'@ai-sdk/provider-utils': 4.0.23(zod@4.3.6)
ai: 6.0.154(zod@4.3.6)
react: 18.2.0
swr: 2.4.1(react@18.2.0)
throttleit: 2.1.0
transitivePeerDependencies:
- zod
'@alloc/quick-lru@5.2.0': {}
'@authenio/xml-encryption@2.0.2':
@@ -8601,21 +8661,21 @@ snapshots:
'@balena/dockerignore@1.0.2': {}
'@better-auth/api-key@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(334901c35c1fcda64bb596793b2e4934))':
'@better-auth/api-key@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(219be630f7f6fef2e235cef94eaddc25))':
dependencies:
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
better-auth: 1.5.4(334901c35c1fcda64bb596793b2e4934)
better-auth: 1.5.4(219be630f7f6fef2e235cef94eaddc25)
zod: 4.3.6
'@better-auth/api-key@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(febde88eaf587188179e6ecc47119e50))':
'@better-auth/api-key@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(8933545d763d3f096150f97f9213a424))':
dependencies:
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
better-auth: 1.5.4(febde88eaf587188179e6ecc47119e50)
better-auth: 1.5.4(8933545d763d3f096150f97f9213a424)
zod: 4.3.6
'@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.28.11)(mongodb@7.1.0)(mysql2@3.15.3)(nanostores@1.1.1)(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))':
'@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.28.11)(mongodb@7.1.0)(mysql2@3.15.3)(nanostores@1.1.1)(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1))':
dependencies:
'@babel/core': 7.29.0
'@babel/preset-react': 7.28.5(@babel/core@7.29.0)
@@ -8627,7 +8687,7 @@ snapshots:
'@mrleebo/prisma-ast': 0.13.1
'@prisma/client': 5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))
'@types/pg': 8.16.0
better-auth: 1.4.21(b1bc00b9e18c5e6af4e13b03fc4304ac)
better-auth: 1.4.21(db78b83f9b5449d160708cdf9d272aa3)
better-sqlite3: 12.6.2
c12: 3.3.3
chalk: 5.6.2
@@ -8761,24 +8821,24 @@ snapshots:
'@prisma/client': 5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))
prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)
'@better-auth/sso@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(334901c35c1fcda64bb596793b2e4934))(better-call@2.0.2(zod@4.3.6))':
'@better-auth/sso@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(219be630f7f6fef2e235cef94eaddc25))(better-call@2.0.2(zod@4.3.6))':
dependencies:
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
better-auth: 1.5.4(334901c35c1fcda64bb596793b2e4934)
better-auth: 1.5.4(219be630f7f6fef2e235cef94eaddc25)
better-call: 2.0.2(zod@4.3.6)
fast-xml-parser: 5.5.1
jose: 6.1.3
samlify: 2.10.2
zod: 4.3.6
'@better-auth/sso@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(febde88eaf587188179e6ecc47119e50))(better-call@2.0.2(zod@4.3.6))':
'@better-auth/sso@1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(8933545d763d3f096150f97f9213a424))(better-call@2.0.2(zod@4.3.6))':
dependencies:
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
better-auth: 1.5.4(febde88eaf587188179e6ecc47119e50)
better-auth: 1.5.4(8933545d763d3f096150f97f9213a424)
better-call: 2.0.2(zod@4.3.6)
fast-xml-parser: 5.5.1
jose: 6.1.3
@@ -8946,7 +9006,7 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@dokploy/trpc-openapi@0.0.18(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)':
'@dokploy/trpc-openapi@0.0.19(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)':
dependencies:
'@trpc/server': 11.10.0(typescript@5.9.3)
co-body: 6.2.0
@@ -12244,6 +12304,7 @@ snapshots:
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
optional: true
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))':
dependencies:
@@ -12252,7 +12313,6 @@ snapshots:
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
optional: true
'@vitest/pretty-format@4.0.18':
dependencies:
@@ -12337,6 +12397,14 @@ snapshots:
transitivePeerDependencies:
- zod
ai@6.0.154(zod@4.3.6):
dependencies:
'@ai-sdk/gateway': 3.0.94(zod@4.3.6)
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.23(zod@4.3.6)
'@opentelemetry/api': 1.9.0
zod: 4.3.6
ai@6.0.97(zod@4.3.6):
dependencies:
'@ai-sdk/gateway': 3.0.53(zod@4.3.6)
@@ -12457,7 +12525,7 @@ snapshots:
before-after-hook@2.2.3: {}
better-auth@1.4.21(b1bc00b9e18c5e6af4e13b03fc4304ac):
better-auth@1.4.21(db78b83f9b5449d160708cdf9d272aa3):
dependencies:
'@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))
@@ -12483,9 +12551,9 @@ snapshots:
prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
better-auth@1.5.4(334901c35c1fcda64bb596793b2e4934):
better-auth@1.5.4(219be630f7f6fef2e235cef94eaddc25):
dependencies:
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/drizzle-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))
@@ -12520,7 +12588,7 @@ snapshots:
transitivePeerDependencies:
- '@cloudflare/workers-types'
better-auth@1.5.4(febde88eaf587188179e6ecc47119e50):
better-auth@1.5.4(8933545d763d3f096150f97f9213a424):
dependencies:
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/drizzle-adapter': 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))
@@ -15935,6 +16003,12 @@ snapshots:
- '@types/react'
- debug
swr@2.4.1(react@18.2.0):
dependencies:
dequal: 2.0.3
react: 18.2.0
use-sync-external-store: 1.6.0(react@18.2.0)
symbol-observable@1.2.0: {}
tailwind-merge@2.6.1: {}
@@ -16025,6 +16099,8 @@ snapshots:
dependencies:
real-require: 0.2.0
throttleit@2.1.0: {}
tiny-invariant@1.3.3: {}
tiny-warning@1.0.3: {}
@@ -16278,13 +16354,13 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)):
vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@5.9.3)
optionalDependencies:
vite: 7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
- typescript
@@ -16303,6 +16379,7 @@ snapshots:
jiti: 1.21.7
tsx: 4.16.2
yaml: 2.8.1
optional: true
vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
dependencies:
@@ -16318,7 +16395,6 @@ snapshots:
jiti: 2.6.1
tsx: 4.16.2
yaml: 2.8.1
optional: true
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1):
dependencies:
@@ -16357,6 +16433,7 @@ snapshots:
- terser
- tsx
- yaml
optional: true
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
dependencies:
@@ -16395,7 +16472,6 @@ snapshots:
- terser
- tsx
- yaml
optional: true
w3c-keyname@2.2.8: {}