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:
Mauricio Siu
2026-04-09 11:19:42 -06:00
parent 090c0226ed
commit e508f3143f
15 changed files with 1229 additions and 3 deletions

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

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

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

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

View File

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

View File

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

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,
@@ -466,4 +467,39 @@ export const libsqlRouter = createTRPCRouter({
});
return true;
}),
readLogs: protectedProcedure
.input(
apiFindOneLibsql.extend({
tail: z.number().int().min(1).max(10000).default(100),
since: z
.string()
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
.default("all"),
search: z
.string()
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
.optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.libsqlId, "read");
const libsql = await findLibsqlById(input.libsqlId);
if (
libsql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this LibSQL",
});
}
return await getContainerLogs(
libsql.appName,
input.tail,
input.since,
input.search,
libsql.serverId,
);
}),
});

View File

@@ -9,6 +9,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,
);
}),
});

View File

@@ -10,6 +10,7 @@ import {
findMongoById,
findProjectById,
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -601,4 +602,39 @@ export const mongoRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.input(
apiFindOneMongo.extend({
tail: z.number().int().min(1).max(10000).default(100),
since: z
.string()
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
.default("all"),
search: z
.string()
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
.optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.mongoId, "read");
const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MongoDB",
});
}
return await getContainerLogs(
mongo.appName,
input.tail,
input.since,
input.search,
mongo.serverId,
);
}),
});

View File

@@ -9,6 +9,7 @@ import {
findEnvironmentById,
findMySqlById,
findProjectById,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -604,4 +605,39 @@ export const mysqlRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.input(
apiFindOneMySql.extend({
tail: z.number().int().min(1).max(10000).default(100),
since: z
.string()
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
.default("all"),
search: z
.string()
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
.optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.mysqlId, "read");
const mysql = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MySQL",
});
}
return await getContainerLogs(
mysql.appName,
input.tail,
input.since,
input.search,
mysql.serverId,
);
}),
});

View File

@@ -9,6 +9,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,
);
}),
});

View File

@@ -8,6 +8,7 @@ import {
findEnvironmentById,
findProjectById,
findRedisById,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -587,4 +588,39 @@ export const redisRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.input(
apiFindOneRedis.extend({
tail: z.number().int().min(1).max(10000).default(100),
since: z
.string()
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
.default("all"),
search: z
.string()
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
.optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.redisId, "read");
const redis = await findRedisById(input.redisId);
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Redis",
});
}
return await getContainerLogs(
redis.appName,
input.tail,
input.since,
input.search,
redis.serverId,
);
}),
});