mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-11 00:45:26 +02:00
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.
This commit is contained in:
410
apps/dokploy/components/dashboard/ai-chat/chat-panel.tsx
Normal file
410
apps/dokploy/components/dashboard/ai-chat/chat-panel.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
"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(() => {
|
||||
if (query.applicationId && typeof query.applicationId === "string") {
|
||||
return { type: "application" as const, id: query.applicationId };
|
||||
}
|
||||
if (query.composeId && typeof query.composeId === "string") {
|
||||
return { type: "compose" as const, id: query.composeId };
|
||||
}
|
||||
if (query.projectId && typeof query.projectId === "string") {
|
||||
return { type: "project" as const, id: query.projectId };
|
||||
}
|
||||
return { type: "general" as const, id: "" };
|
||||
}, [query.applicationId, query.composeId, query.projectId, pathname]);
|
||||
}
|
||||
|
||||
export function ChatPanel() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [aiId, setAiId] = useState<string>("");
|
||||
const [input, setInput] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const context = useChatContext();
|
||||
const aiIdRef = useRef(aiId);
|
||||
const contextRef = useRef(context);
|
||||
aiIdRef.current = aiId;
|
||||
contextRef.current = context;
|
||||
|
||||
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const enabledProviders = providers ?? [];
|
||||
|
||||
const { messages, sendMessage, status, setMessages } = useChat({
|
||||
id: "dokploy-chat",
|
||||
transport: new DefaultChatTransport({
|
||||
api: "/api/ai/chat",
|
||||
body: () => ({ aiId: aiIdRef.current, context: contextRef.current }),
|
||||
}),
|
||||
});
|
||||
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
|
||||
useEffect(() => {
|
||||
if (!aiId && enabledProviders.length > 0 && enabledProviders[0]) {
|
||||
setAiId(enabledProviders[0].aiId);
|
||||
}
|
||||
}, [enabledProviders, aiId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, status]);
|
||||
|
||||
if (enabledProviders.length === 0) return null;
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || !aiId || isLoading) return;
|
||||
sendMessage({ text: input });
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const contextLabel =
|
||||
context.type === "general"
|
||||
? "General"
|
||||
: context.type;
|
||||
|
||||
// Check if the AI is currently in a tool-calling phase (no text yet, just tools)
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const isThinking =
|
||||
isLoading &&
|
||||
lastMessage?.role === "assistant" &&
|
||||
lastMessage.parts.every(
|
||||
(p) => p.type !== "text" || !(p as { text?: string }).text?.trim(),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-6 right-6 z-50 h-12 w-12 rounded-full shadow-lg"
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
<SheetTitle className="text-base">AI Assistant</SheetTitle>
|
||||
{isLoading && (
|
||||
<Badge variant="secondary" className="text-xs animate-pulse">
|
||||
thinking...
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
Chat with AI to manage your infrastructure
|
||||
</SheetDescription>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<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">
|
||||
{contextLabel}
|
||||
</Badge>
|
||||
{messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setMessages([])}
|
||||
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-10 w-10 opacity-50" />
|
||||
<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 === "project"
|
||||
? [
|
||||
"How many services do I have?",
|
||||
"Show me all environments",
|
||||
"Which services are failing?",
|
||||
]
|
||||
: [
|
||||
"List all my projects",
|
||||
"Show project overview",
|
||||
]
|
||||
).map((suggestion) => (
|
||||
<Button
|
||||
key={suggestion}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7"
|
||||
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-primary text-primary-foreground">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// Assistant message
|
||||
const toolParts = message.parts.filter(
|
||||
(p) => p.type === "dynamic-tool",
|
||||
);
|
||||
const textParts = message.parts.filter(
|
||||
(p) => p.type === "text" && (p as { text?: string }).text?.trim(),
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={message.id} className="flex justify-start">
|
||||
<div className="max-w-[90%] space-y-2">
|
||||
{/* Tool calls section */}
|
||||
{toolParts.length > 0 && (
|
||||
<div className="rounded-lg border border-dashed px-3 py-2 space-y-1">
|
||||
{toolParts.map((part) => {
|
||||
if (part.type !== "dynamic-tool") return null;
|
||||
return (
|
||||
<ToolCallDisplay
|
||||
key={part.toolCallId}
|
||||
toolName={part.toolName}
|
||||
state={part.state}
|
||||
output={
|
||||
part.state === "output-available"
|
||||
? part.output
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text response */}
|
||||
{textParts.map((part, i) => {
|
||||
if (part.type !== "text") return null;
|
||||
const text = (part as { text: string }).text;
|
||||
if (!text.trim()) return null;
|
||||
return (
|
||||
<div
|
||||
key={`text-${message.id}-${i}`}
|
||||
className="rounded-lg bg-muted px-3 py-2 text-sm prose prose-sm dark:prose-invert max-w-none break-words"
|
||||
>
|
||||
<ReactMarkdown>{text}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Loading indicator when waiting for first response */}
|
||||
{isLoading &&
|
||||
lastMessage?.role === "user" && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-lg border border-dashed 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
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={
|
||||
aiId ? "Ask anything..." : "Select a provider first..."
|
||||
}
|
||||
disabled={!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"
|
||||
disabled={!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({
|
||||
toolName,
|
||||
state,
|
||||
output,
|
||||
}: {
|
||||
toolName: string;
|
||||
state: string;
|
||||
output?: unknown;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isRunning =
|
||||
state === "input-streaming" || state === "input-available";
|
||||
const isDone = state === "output-available";
|
||||
const isError = state === "output-error";
|
||||
|
||||
const outputText = output
|
||||
? typeof output === "string"
|
||||
? output
|
||||
: JSON.stringify(output, null, 2)
|
||||
: null;
|
||||
|
||||
// Format tool name for display: "application-one" → "Application One"
|
||||
const displayName = toolName
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-1.5 text-xs">
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground mt-0.5 shrink-0" />
|
||||
) : isDone ? (
|
||||
<Check className="h-3 w-3 text-green-500 mt-0.5 shrink-0" />
|
||||
) : isError ? (
|
||||
<X className="h-3 w-3 text-destructive mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground mt-0.5 shrink-0" />
|
||||
)}
|
||||
|
||||
{outputText ? (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>{displayName}</span>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-1 p-2 bg-background rounded text-[10px] overflow-x-auto max-h-[150px] overflow-y-auto leading-tight">
|
||||
{outputText.length > 2000
|
||||
? `${outputText.slice(0, 2000)}\n... (truncated)`
|
||||
: outputText}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{isRunning ? `Calling ${displayName}...` : displayName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
104
apps/dokploy/pages/api/ai/chat.ts
Normal file
104
apps/dokploy/pages/api/ai/chat.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { getAiSettingById } from "@dokploy/server/services/ai";
|
||||
import {
|
||||
type ChatContext,
|
||||
getAllTools,
|
||||
getReadTools,
|
||||
} from "@dokploy/server/utils/ai/chat-tools";
|
||||
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
||||
import { convertToModelMessages, stepCountIs, streamText } from "ai";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
function buildSystemPrompt(context: ChatContext) {
|
||||
return `You are an autonomous DevOps agent inside Dokploy, a self-hosted PaaS that uses Docker.
|
||||
|
||||
YOU ARE AN AGENT — act autonomously:
|
||||
- NEVER ask the user for IDs, parameters, or information you can find yourself with tools
|
||||
- NEVER respond without calling tools first — always investigate before answering
|
||||
- Chain multiple tool calls: get info → analyze → act → verify
|
||||
- If one tool gives you data you need for another tool, call the next tool immediately
|
||||
|
||||
${context.type !== "general" ? `CURRENT CONTEXT: You are on a ${context.type} page. The ${context.type}Id is "${context.id}" — all tools already use this ID automatically. You do NOT need to pass it.` : ""}
|
||||
|
||||
Dokploy data model:
|
||||
- Project → Environment(s) → Services (applications, compose, postgres, mysql, redis, mongo, mariadb, libsql)
|
||||
- Each application/compose has deployments with status (done/error/running/cancelled)
|
||||
- To investigate a failed build: call list-deployments → find the one with status "error" → call read-deployment-logs with that deploymentId → analyze the error
|
||||
|
||||
Guidelines:
|
||||
- Be concise — summarize findings, don't dump raw JSON
|
||||
- Before destructive actions (stop, delete), explain what you'll do first
|
||||
- When updating env vars, ALWAYS get current ones first (from get-application-info) and include ALL existing vars plus the new ones
|
||||
- If a tool errors, try a different approach`;
|
||||
}
|
||||
|
||||
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: "" };
|
||||
|
||||
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);
|
||||
const model = provider(aiSettings.model);
|
||||
|
||||
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 || "",
|
||||
};
|
||||
|
||||
// All tools (read + write) — prepareStep controls which are active
|
||||
const allTools = getAllTools(context, toolConfig);
|
||||
const readToolNames = Object.keys(getReadTools(context, toolConfig));
|
||||
|
||||
const modelMessages = await convertToModelMessages(messages);
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
system: buildSystemPrompt(context),
|
||||
messages: modelMessages,
|
||||
tools: allTools,
|
||||
prepareStep: ({ steps }) => {
|
||||
// First 3 steps: only read tools (investigate first)
|
||||
// After that: all tools (can take action)
|
||||
if (steps.length < 3) {
|
||||
return {
|
||||
activeTools: readToolNames as (keyof typeof allTools)[],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
stopWhen: stepCountIs(10),
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
findGitProviderById,
|
||||
findProjectById,
|
||||
getApplicationStats,
|
||||
getContainerLogs,
|
||||
IS_CLOUD,
|
||||
mechanizeDockerContainer,
|
||||
readConfig,
|
||||
@@ -1101,4 +1102,39 @@ export const applicationRouter = createTRPCRouter({
|
||||
total: countResult[0]?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOneApplication.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.applicationId, "read");
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this application",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
application.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
application.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
findGitProviderById,
|
||||
findProjectById,
|
||||
findServerById,
|
||||
getAccessibleServerIds,
|
||||
getComposeContainer,
|
||||
getContainerLogs,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
loadServices,
|
||||
@@ -30,7 +32,6 @@ import {
|
||||
stopCompose,
|
||||
updateCompose,
|
||||
updateDeploymentStatus,
|
||||
getAccessibleServerIds,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -1130,4 +1131,43 @@ export const composeRouter = createTRPCRouter({
|
||||
total: countResult[0]?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindCompose.extend({
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-zA-Z0-9.\-_]+$/, "Invalid container id."),
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.composeId, "read");
|
||||
const compose = await findComposeById(input.composeId);
|
||||
if (
|
||||
compose.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this compose",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
input.containerId,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
compose.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -466,4 +467,39 @@ export const libsqlRouter = createTRPCRouter({
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOneLibsql.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.libsqlId, "read");
|
||||
const libsql = await findLibsqlById(input.libsqlId);
|
||||
if (
|
||||
libsql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this LibSQL",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
libsql.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
libsql.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findEnvironmentById,
|
||||
findMariadbById,
|
||||
findProjectById,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
@@ -590,4 +591,39 @@ export const mariadbRouter = createTRPCRouter({
|
||||
]);
|
||||
return { items, total: countResult[0]?.count ?? 0 };
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOneMariaDB.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.mariadbId, "read");
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
if (
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MariaDB",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
mariadb.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
mariadb.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
findMongoById,
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
@@ -601,4 +602,39 @@ export const mongoRouter = createTRPCRouter({
|
||||
]);
|
||||
return { items, total: countResult[0]?.count ?? 0 };
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOneMongo.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.mongoId, "read");
|
||||
const mongo = await findMongoById(input.mongoId);
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MongoDB",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
mongo.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
mongo.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findEnvironmentById,
|
||||
findMySqlById,
|
||||
findProjectById,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
@@ -604,4 +605,39 @@ export const mysqlRouter = createTRPCRouter({
|
||||
]);
|
||||
return { items, total: countResult[0]?.count ?? 0 };
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOneMySql.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.mysqlId, "read");
|
||||
const mysql = await findMySqlById(input.mysqlId);
|
||||
if (
|
||||
mysql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MySQL",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
mysql.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
mysql.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
@@ -614,4 +615,39 @@ export const postgresRouter = createTRPCRouter({
|
||||
]);
|
||||
return { items, total: countResult[0]?.count ?? 0 };
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOnePostgres.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.postgresId, "read");
|
||||
const postgres = await findPostgresById(input.postgresId);
|
||||
if (
|
||||
postgres.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Postgres",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
postgres.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
postgres.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
findEnvironmentById,
|
||||
findProjectById,
|
||||
findRedisById,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
@@ -587,4 +588,39 @@ export const redisRouter = createTRPCRouter({
|
||||
]);
|
||||
return { items, total: countResult[0]?.count ?? 0 };
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOneRedis.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.redisId, "read");
|
||||
const redis = await findRedisById(input.redisId);
|
||||
if (
|
||||
redis.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Redis",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
redis.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
redis.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user