From 090c0226eda8e1c164af561ed3f21a581f26d34e Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 9 Apr 2026 09:27:31 -0600 Subject: [PATCH] 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. --- .../deployments/show-deployment.tsx | 2 + .../dashboard/docker/logs/analyze-logs.tsx | 170 ++++++++++ .../dashboard/docker/logs/docker-logs-id.tsx | 2 + .../project/ai/template-generator.tsx | 14 +- .../dashboard/settings/handle-ai.tsx | 319 ++++++++++++------ apps/dokploy/server/api/routers/ai.ts | 116 +++++++ packages/server/src/services/ai.ts | 238 ++++++------- .../server/src/utils/ai/select-ai-provider.ts | 27 ++ 8 files changed, 651 insertions(+), 237 deletions(-) create mode 100644 apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx index 4285f04c4..9f078f9d2 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx @@ -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 = ({ )} + {serverId && (
diff --git a/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx new file mode 100644 index 000000000..f330c3fe2 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx @@ -0,0 +1,170 @@ +"use client"; +import { Bot, Loader2, RotateCcw, X } from "lucide-react"; +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(""); + 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 ( + { + setOpen(isOpen); + if (!isOpen) { + reset(); + setAiId(""); + } + }} + > + + + + +
+
+ + Log Analysis +
+ +
+
+ {!data?.analysis ? ( + <> + + + + ) : ( + <> +
+
+ {data.analysis} +
+
+
+ + +
+ + )} +
+
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index 59b939008..ace20b343 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -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 = ({ Download logs +
{isPaused && ( diff --git a/apps/dokploy/components/dashboard/project/ai/template-generator.tsx b/apps/dokploy/components/dashboard/project/ai/template-generator.tsx index 76a491aa0..64d4b0776 100644 --- a/apps/dokploy/components/dashboard/project/ai/template-generator.tsx +++ b/apps/dokploy/components/dashboard/project/ai/template-generator.tsx @@ -298,7 +298,19 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
+ + + - {field.value - ? (selectedModel?.id ?? field.value) - : "Select a model"} - - - - - - - + + + + {modelSearch ? ( + + ) : ( + "No models found." + )} + + {displayModels.map((model) => { + const isSelected = field.value === model.id; + return ( + { + field.onChange(model.id); + setModelPopoverOpen(false); + setModelSearch(""); + }} + > + + {model.id} + + ); + })} + + + + + ) : ( + + - - No models found. - {displayModels.map((model) => { - const isSelected = field.value === model.id; - return ( - { - field.onChange(model.id); - setModelPopoverOpen(false); - setModelSearch(""); - }} - > - - {model.id} - - ); - })} - - - - - - Select an AI model to use - - - - ); - }} - /> - )} + + )} +
+
+ + Select a model from the list or type a custom model name + + + + ); + }} + /> { )} /> -
+
+ @@ -383,3 +475,42 @@ export const HandleAi = ({ aiId }: Props) => { ); }; + +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 ( + + ); +} diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index a4527497d..139227221 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -25,9 +25,11 @@ import { findProjectById } from "@dokploy/server/services/project"; import { getProviderHeaders, getProviderName, + selectAIProvider, type Model, } from "@dokploy/server/utils/ai/select-ai-provider"; import { TRPCError } from "@trpc/server"; +import { generateText } from "ai"; import { z } from "zod"; import { slugify } from "@/lib/slug"; import { @@ -95,6 +97,30 @@ export const aiRouter = createTRPCRouter({ owned_by: "perplexity", }, ] as Model[]; + case "zai": + return [ + { + id: "glm-5", + object: "model", + created: Date.now(), + owned_by: "zai", + }, + { + id: "glm-4.7", + object: "model", + created: Date.now(), + owned_by: "zai", + }, + ] as Model[]; + case "minimax": + return [ + { + id: "MiniMax-M2.7", + object: "model", + created: Date.now(), + owned_by: "minimax", + }, + ] as Model[]; default: if (!input.apiKey) throw new TRPCError({ @@ -174,6 +200,96 @@ export const aiRouter = createTRPCRouter({ return await deleteAiSettings(input.aiId); }), + getEnabledProviders: protectedProcedure.query(async ({ ctx }) => { + const settings = await getAiSettingsByOrganizationId( + ctx.session.activeOrganizationId, + ); + return settings + .filter((s) => s.isEnabled) + .map((s) => ({ aiId: s.aiId, name: s.name, model: s.model })); + }), + + analyzeLogs: protectedProcedure + .input( + z.object({ + aiId: z.string().min(1), + logs: z.string().min(1), + context: z.enum(["build", "runtime"]), + }), + ) + .mutation(async ({ input }) => { + try { + const aiSettings = await getAiSettingById(input.aiId); + if (!aiSettings?.isEnabled) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "AI provider is not enabled", + }); + } + + const provider = selectAIProvider(aiSettings); + const model = provider(aiSettings.model); + + const contextLabel = + input.context === "build" ? "build/deployment" : "runtime/container"; + + const result = await generateText({ + model, + prompt: `You are a DevOps engineer analyzing ${contextLabel} logs. Analyze the following logs and provide: + +1. **Summary**: A brief summary of what's happening +2. **Issues Found**: Any errors, warnings, or problems detected +3. **Root Cause**: The most likely root cause if there are errors +4. **Suggested Fix**: Actionable steps to resolve the issues + +Be concise and practical. Focus on the most important issues. If the logs look healthy, say so briefly. + +Logs: +${input.logs}`, + }); + + return { analysis: result.text }; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error ? error.message : `Analysis failed: ${error}`, + }); + } + }), + + testConnection: protectedProcedure + .input( + z.object({ + apiUrl: z.string().min(1), + apiKey: z.string(), + model: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + try { + const provider = selectAIProvider({ + apiUrl: input.apiUrl, + apiKey: input.apiKey, + }); + const model = provider(input.model); + const result = await generateText({ + model, + prompt: "Reply with 'ok'", + }); + if (!result.text) { + throw new Error("No response received from the model"); + } + return { success: true, message: "Connection successful" }; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error ? error.message : `Connection failed: ${error}`, + }); + } + }), + suggest: protectedProcedure .input( z.object({ diff --git a/packages/server/src/services/ai.ts b/packages/server/src/services/ai.ts index ccfb31fc1..6e90d82d0 100644 --- a/packages/server/src/services/ai.ts +++ b/packages/server/src/services/ai.ts @@ -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; diff --git a/packages/server/src/utils/ai/select-ai-provider.ts b/packages/server/src/utils/ai/select-ai-provider.ts index b6477c573..52d7c6224 100644 --- a/packages/server/src/utils/ai/select-ai-provider.ts +++ b/packages/server/src/utils/ai/select-ai-provider.ts @@ -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",