mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-18 22:51:36 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0c8d80cde | |||
| 49d250aaa9 | |||
| bef77ac84f | |||
| a67d9e3522 | |||
| e38a980d9f | |||
| 829f8af63f | |||
| 41cecde735 | |||
| 3e75dc64f3 | |||
| 7430afd701 | |||
| 837730038c | |||
| 53464e9096 | |||
| 136a09d016 | |||
| c867a3c4c9 | |||
| 471e91caeb | |||
| 37df4e484e | |||
| 5ff8d05ff7 | |||
| a9871f3a8d | |||
| 0049421095 | |||
| 3d67665c33 | |||
| 2bfcac58b0 | |||
| a7d8378f52 | |||
| bdefc02e77 | |||
| 293e19703a | |||
| cd9edb290b | |||
| b5473e63fd | |||
| 949ac8e0a2 | |||
| 5ccc15f00d | |||
| 1f1acacf58 |
@@ -111,6 +111,54 @@ describe("route53Client.listRecords", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("decodes octal escapes such as \\052 for wildcard names", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "\\052.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
},
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records[0]).toMatchObject({
|
||||
id: "A:*.example.com",
|
||||
name: "*.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps escaped dots so they stay distinct from label separators", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "a\\056b.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
},
|
||||
{
|
||||
Name: "a.b.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "5.6.7.8" }],
|
||||
},
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records.map((r) => r.id)).toEqual([
|
||||
"A:a\\056b.example.com",
|
||||
"A:a.b.example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
@@ -355,6 +403,24 @@ describe("route53Client.deleteRecord", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds a wildcard record set that Route53 returns escaped", async () => {
|
||||
const existingSet = {
|
||||
Name: "\\052.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
};
|
||||
send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] });
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.deleteRecord(config, "Z123", "A:*.example.com");
|
||||
|
||||
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
|
||||
{ Action: "DELETE", ResourceRecordSet: existingSet },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when the record no longer exists", async () => {
|
||||
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ const ENTERPRISE_RESOURCES = [
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
"ai",
|
||||
];
|
||||
|
||||
describe("enterpriseOnlyResources set", () => {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// apiReadTraefikConfig validates against the real local paths(), which outside
|
||||
// production live under the working directory.
|
||||
const traefikPath = vi.hoisted(() => `${process.cwd()}/.docker/traefik`);
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findServerById: vi.fn(),
|
||||
readDirectory: vi.fn(),
|
||||
readConfigInPath: vi.fn(),
|
||||
writeTraefikConfigInPath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
checkPermission: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
paths: () => ({ MAIN_TRAEFIK_PATH: traefikPath }),
|
||||
...mocks,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/audit", () => ({
|
||||
audit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/queues/queueSetup", () => ({
|
||||
cleanAllDeploymentQueue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/utils/backup", () => ({
|
||||
removeJob: vi.fn(),
|
||||
schedule: vi.fn(),
|
||||
}));
|
||||
|
||||
const { settingsRouter } = await import("@/server/api/routers/settings");
|
||||
|
||||
const caller = settingsRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-a" },
|
||||
user: { id: "user-1", email: "user@example.com", role: "member" },
|
||||
} as Parameters<typeof settingsRouter.createCaller>[0]);
|
||||
|
||||
const filePath = path.join(traefikPath, "dynamic/app.yml");
|
||||
|
||||
describe("traefik file endpoints scope remote servers to the active organization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findServerById.mockImplementation(async (serverId: string) => ({
|
||||
serverId,
|
||||
organizationId: serverId === "server-a" ? "org-a" : "org-b",
|
||||
}));
|
||||
mocks.readDirectory.mockResolvedValue([]);
|
||||
mocks.readConfigInPath.mockResolvedValue("http: {}");
|
||||
});
|
||||
|
||||
it("rejects listing a server from another organization", async () => {
|
||||
await expect(
|
||||
caller.readDirectories({ serverId: "server-b" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.readDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects writing to a server from another organization", async () => {
|
||||
await expect(
|
||||
caller.updateTraefikFile({
|
||||
path: filePath,
|
||||
traefikConfig: "http: {}",
|
||||
serverId: "server-b",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.writeTraefikConfigInPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects reading from a server from another organization", async () => {
|
||||
await expect(
|
||||
caller.readTraefikFile({ path: filePath, serverId: "server-b" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.readConfigInPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows servers of the active organization", async () => {
|
||||
await caller.readDirectories({ serverId: "server-a" });
|
||||
await caller.readTraefikFile({ path: filePath, serverId: "server-a" });
|
||||
await caller.updateTraefikFile({
|
||||
path: filePath,
|
||||
traefikConfig: "http: {}",
|
||||
serverId: "server-a",
|
||||
});
|
||||
|
||||
expect(mocks.readDirectory).toHaveBeenCalledWith(traefikPath, "server-a");
|
||||
expect(mocks.readConfigInPath).toHaveBeenCalledWith(filePath, "server-a");
|
||||
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
|
||||
filePath,
|
||||
"http: {}",
|
||||
"server-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not look up a server when none is given", async () => {
|
||||
await caller.readDirectories({});
|
||||
await caller.updateTraefikFile({
|
||||
path: filePath,
|
||||
traefikConfig: "http: {}",
|
||||
});
|
||||
|
||||
expect(mocks.findServerById).not.toHaveBeenCalled();
|
||||
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
|
||||
filePath,
|
||||
"http: {}",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ describe("pipeBetweenServers", () => {
|
||||
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
|
||||
});
|
||||
expect(bytes).toBe(11);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("pipes data larger than the pipe buffer unchanged", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
|
||||
@@ -47,7 +47,7 @@ describe("pipeBetweenServers", () => {
|
||||
target: { serverId: null, command: "echo boom >&2; exit 3" },
|
||||
}),
|
||||
).rejects.toThrow("target exited with code 3: boom");
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("reports a source failure", async () => {
|
||||
await expect(
|
||||
@@ -56,5 +56,5 @@ describe("pipeBetweenServers", () => {
|
||||
target: { serverId: null, command: "cat > /dev/null" },
|
||||
}),
|
||||
).rejects.toThrow("source exited with code 2");
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -11,9 +11,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
const stripePromise = loadStripe(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
|
||||
: null;
|
||||
|
||||
type Tier = "hobby" | "startup";
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ import { HandleAiProviders } from "./handle-ai-providers";
|
||||
export const AiForm = () => {
|
||||
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
|
||||
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: currentUser } = api.user.get.useQuery();
|
||||
|
||||
const isOrgAdmin =
|
||||
currentUser?.role === "owner" || currentUser?.role === "admin";
|
||||
|
||||
@@ -36,7 +38,9 @@ export const AiForm = () => {
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
{isOrgAdmin && <HandleAiProviders />}
|
||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
||||
{permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && (
|
||||
<HandleAi />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
@@ -53,7 +57,7 @@ export const AiForm = () => {
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any AI configurations
|
||||
</span>
|
||||
<HandleAi />
|
||||
{permissions?.ai.create && <HandleAi />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
|
||||
@@ -70,33 +74,37 @@ export const AiForm = () => {
|
||||
<CardDescription>{config.model}</CardDescription>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<HandleAi aiId={config.aiId} />
|
||||
<DialogAction
|
||||
title="Delete AI"
|
||||
description="Are you sure you want to delete this AI?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
aiId: config.aiId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("AI deleted successfully");
|
||||
refetch();
|
||||
{permissions?.ai.update && (
|
||||
<HandleAi aiId={config.aiId} />
|
||||
)}
|
||||
{permissions?.ai.delete && (
|
||||
<DialogAction
|
||||
title="Delete AI"
|
||||
description="Are you sure you want to delete this AI?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
aiId: config.aiId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting AI");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
toast.success("AI deleted successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting AI");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,9 +42,9 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const stripePromise = loadStripe(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
|
||||
: null;
|
||||
|
||||
/** Precio legacy / Hobby: $4.50/mo primer servidor, $3.50 siguientes; anual $45.90 primero, $35.70 siguientes. */
|
||||
export const calculatePrice = (count: number, isAnnual = false) => {
|
||||
|
||||
@@ -148,6 +148,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
{
|
||||
apiUrl: apiUrl ?? "",
|
||||
apiKey: apiKey ?? "",
|
||||
...(aiId ? { aiId } : {}),
|
||||
},
|
||||
{
|
||||
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
|
||||
@@ -482,6 +483,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
apiUrl={apiUrl}
|
||||
apiKey={apiKey}
|
||||
model={form.watch("model")}
|
||||
aiId={aiId}
|
||||
/>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{aiId ? "Update" : "Create"}
|
||||
@@ -498,10 +500,12 @@ function TestConnectionButton({
|
||||
apiUrl,
|
||||
apiKey,
|
||||
model,
|
||||
aiId,
|
||||
}: {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
aiId?: string;
|
||||
}) {
|
||||
const { mutate, isPending } = api.ai.testConnection.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -521,7 +525,9 @@ function TestConnectionButton({
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isDisabled || isPending}
|
||||
onClick={() => mutate({ apiUrl, apiKey, model })}
|
||||
onClick={() =>
|
||||
mutate({ apiUrl, apiKey, model, ...(aiId ? { aiId } : {}) })
|
||||
}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -351,7 +351,13 @@ const MENU: Menu = {
|
||||
icon: BotIcon,
|
||||
url: "/dashboard/settings/ai",
|
||||
isSingle: true,
|
||||
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
||||
isEnabled: ({ permissions }) =>
|
||||
!!(
|
||||
permissions?.ai.read ||
|
||||
permissions?.ai.create ||
|
||||
permissions?.ai.update ||
|
||||
permissions?.ai.delete
|
||||
),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
|
||||
@@ -185,6 +185,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
||||
description:
|
||||
"Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to",
|
||||
},
|
||||
ai: {
|
||||
label: "AI Settings",
|
||||
description:
|
||||
"Manage AI providers, models, and use AI features (log analysis, suggestions)",
|
||||
},
|
||||
};
|
||||
|
||||
/** Descriptions for each action within a resource */
|
||||
@@ -495,6 +500,25 @@ const ACTION_META: Record<
|
||||
description: "Remove logs and metrics providers",
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description:
|
||||
"View AI providers and use AI features (log analysis, suggestions)",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Add new AI provider configurations",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
description: "Edit AI provider settings and custom providers",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove AI provider configurations",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Resources that should be hidden from the custom role editor (better-auth internals) */
|
||||
@@ -606,6 +630,7 @@ const ROLE_PRESETS: {
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -357,7 +357,10 @@ export const AdvanceBreadcrumb = () => {
|
||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||
>
|
||||
<FolderInput className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||
<span
|
||||
className="font-medium max-w-[50px] md:max-w-[150px] truncate"
|
||||
title={currentProject?.name}
|
||||
>
|
||||
{currentProject?.name || "Select Project"}
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
|
||||
+13
-9
@@ -123,9 +123,9 @@ const Service = (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2 items-center">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2 items-center">
|
||||
<div className="relative flex flex-row gap-4 items-center">
|
||||
<ShowIconSettings
|
||||
serviceId={applicationId}
|
||||
@@ -136,20 +136,22 @@ const Service = (
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
</div>
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -165,7 +167,9 @@ const Service = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+13
-9
@@ -114,9 +114,9 @@ const Service = (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<div className="flex flex-col gap-4">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2 items-center">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2 items-center">
|
||||
<div className="relative flex flex-row gap-4 items-center">
|
||||
<ShowIconSettings
|
||||
serviceId={composeId}
|
||||
@@ -127,20 +127,22 @@ const Service = (
|
||||
<StatusTooltip status={data?.composeStatus} />
|
||||
</div>
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -156,7 +158,9 @@ const Service = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider>
|
||||
|
||||
+13
-9
@@ -80,9 +80,9 @@ const Libsql = (
|
||||
</Head>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
@@ -90,20 +90,22 @@ const Libsql = (
|
||||
|
||||
<LibsqlIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -119,7 +121,9 @@ const Libsql = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+13
-9
@@ -92,9 +92,9 @@ const Mariadb = (
|
||||
</Head>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
@@ -102,20 +102,22 @@ const Mariadb = (
|
||||
|
||||
<MariadbIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -131,7 +133,9 @@ const Mariadb = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+13
-9
@@ -91,9 +91,9 @@ const Mongo = (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
@@ -101,20 +101,22 @@ const Mongo = (
|
||||
|
||||
<MongodbIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -130,7 +132,9 @@ const Mongo = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+13
-9
@@ -91,9 +91,9 @@ const MySql = (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
@@ -101,20 +101,22 @@ const MySql = (
|
||||
|
||||
<MysqlIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -130,7 +132,9 @@ const MySql = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+13
-9
@@ -90,9 +90,9 @@ const Postgresql = (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
@@ -100,20 +100,22 @@ const Postgresql = (
|
||||
|
||||
<PostgresqlIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -129,7 +131,9 @@ const Postgresql = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+13
-9
@@ -90,9 +90,9 @@ const Redis = (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
@@ -100,20 +100,22 @@ const Redis = (
|
||||
|
||||
<RedisIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -129,7 +131,9 @@ const Redis = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
getAiSettingById,
|
||||
getAiSettingsByOrganizationId,
|
||||
getCustomAiProviders,
|
||||
maskAiApiKey,
|
||||
mergeAiApiKey,
|
||||
resolveAiApiKey,
|
||||
saveAiSettings,
|
||||
saveCustomAiProviders,
|
||||
suggestVariants,
|
||||
@@ -23,6 +26,7 @@ import { createComposeByTemplate } from "@dokploy/server/services/compose";
|
||||
import {
|
||||
addNewService,
|
||||
checkServiceAccess,
|
||||
hasPermission,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
import {
|
||||
@@ -38,23 +42,48 @@ import { slugify } from "@/lib/slug";
|
||||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
withAnyPermission,
|
||||
withPermission,
|
||||
} from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { generatePassword } from "@/templates/utils";
|
||||
|
||||
const resolveInputApiKey = (
|
||||
input: { apiKey: string; apiUrl: string; aiId?: string },
|
||||
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
|
||||
) =>
|
||||
resolveAiApiKey({
|
||||
apiKey: input.apiKey,
|
||||
apiUrl: input.apiUrl,
|
||||
aiId: input.aiId,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
authorize: () => hasPermission(ctx, { ai: ["update"] }),
|
||||
});
|
||||
|
||||
export const aiRouter = createTRPCRouter({
|
||||
one: adminProcedure
|
||||
one: withAnyPermission("ai", ["read", "update"])
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getAiSettingById(input.aiId);
|
||||
.query(async ({ input, ctx }) => {
|
||||
const setting = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return maskAiApiKey(setting);
|
||||
}),
|
||||
|
||||
getModels: protectedProcedure
|
||||
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
getModels: withAnyPermission("ai", ["read", "create", "update"])
|
||||
.input(
|
||||
z.object({
|
||||
apiUrl: z.string().min(1),
|
||||
apiKey: z.string(),
|
||||
aiId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const apiKey = await resolveInputApiKey(input, ctx);
|
||||
const providerName = getProviderName(input.apiUrl);
|
||||
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
|
||||
const headers = getProviderHeaders(input.apiUrl, apiKey);
|
||||
let response = null;
|
||||
switch (providerName) {
|
||||
case "ollama":
|
||||
@@ -62,7 +91,7 @@ export const aiRouter = createTRPCRouter({
|
||||
break;
|
||||
case "gemini":
|
||||
response = await fetch(
|
||||
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
|
||||
`${input.apiUrl}/models?key=${encodeURIComponent(apiKey)}`,
|
||||
{ headers: {} },
|
||||
);
|
||||
break;
|
||||
@@ -125,7 +154,7 @@ export const aiRouter = createTRPCRouter({
|
||||
},
|
||||
] as Model[];
|
||||
default:
|
||||
if (!input.apiKey)
|
||||
if (!apiKey)
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "API key must contain at least 1 character(s)",
|
||||
@@ -177,33 +206,76 @@ export const aiRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}),
|
||||
create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
|
||||
return await saveAiSettings(ctx.session.activeOrganizationId, input);
|
||||
}),
|
||||
|
||||
update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => {
|
||||
return await saveAiSettings(ctx.session.activeOrganizationId, input);
|
||||
}),
|
||||
|
||||
getAll: adminProcedure.query(async ({ ctx }) => {
|
||||
return await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
}),
|
||||
|
||||
get: adminProcedure
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getAiSettingById(input.aiId);
|
||||
create: withPermission("ai", "create")
|
||||
.input(apiCreateAi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await saveAiSettings(
|
||||
ctx.session.activeOrganizationId,
|
||||
input,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "ai",
|
||||
resourceName: input.name,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await deleteAiSettings(input.aiId);
|
||||
update: withPermission("ai", "update")
|
||||
.input(apiUpdateAi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
if (input.apiKey) {
|
||||
input.apiKey = mergeAiApiKey(input.apiKey, existing.apiKey);
|
||||
}
|
||||
const result = await saveAiSettings(
|
||||
ctx.session.activeOrganizationId,
|
||||
input,
|
||||
false,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "ai",
|
||||
resourceId: input.aiId,
|
||||
resourceName: input.name,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
getAll: withAnyPermission("ai", ["read", "create", "update", "delete"]).query(
|
||||
async ({ ctx }) => {
|
||||
const settings = await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return settings.map(({ apiKey, ...rest }) => rest);
|
||||
},
|
||||
),
|
||||
|
||||
delete: withPermission("ai", "delete")
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const setting = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await deleteAiSettings(input.aiId, ctx.session.activeOrganizationId);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "ai",
|
||||
resourceId: input.aiId,
|
||||
resourceName: setting.name,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
|
||||
getCustomProviders: withAnyPermission("ai", [
|
||||
"read",
|
||||
"create",
|
||||
"update",
|
||||
]).query(async ({ ctx }) => {
|
||||
return await getCustomAiProviders(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
@@ -216,7 +288,7 @@ export const aiRouter = createTRPCRouter({
|
||||
);
|
||||
}),
|
||||
|
||||
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
getEnabledProviders: withPermission("ai", "read").query(async ({ ctx }) => {
|
||||
const settings = await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
@@ -225,7 +297,7 @@ export const aiRouter = createTRPCRouter({
|
||||
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
|
||||
}),
|
||||
|
||||
analyzeLogs: protectedProcedure
|
||||
analyzeLogs: withPermission("ai", "read")
|
||||
.input(
|
||||
z.object({
|
||||
aiId: z.string().min(1),
|
||||
@@ -235,7 +307,10 @@ export const aiRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const aiSettings = await getAiSettingById(input.aiId);
|
||||
const aiSettings = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
if (!aiSettings?.isEnabled) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -243,13 +318,6 @@ export const aiRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (aiSettings.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Access denied",
|
||||
});
|
||||
}
|
||||
|
||||
const provider = selectAIProvider(aiSettings);
|
||||
const model = provider(aiSettings.model);
|
||||
|
||||
@@ -283,19 +351,21 @@ ${input.logs}`,
|
||||
}
|
||||
}),
|
||||
|
||||
testConnection: protectedProcedure
|
||||
testConnection: withAnyPermission("ai", ["create", "update"])
|
||||
.input(
|
||||
z.object({
|
||||
apiUrl: z.string().min(1),
|
||||
apiKey: z.string(),
|
||||
model: z.string().min(1),
|
||||
aiId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const apiKey = await resolveInputApiKey(input, ctx);
|
||||
const provider = selectAIProvider({
|
||||
apiUrl: input.apiUrl,
|
||||
apiKey: input.apiKey,
|
||||
apiKey,
|
||||
});
|
||||
const model = provider(input.model);
|
||||
const result = await generateText({
|
||||
@@ -317,7 +387,7 @@ ${input.logs}`,
|
||||
}
|
||||
}),
|
||||
|
||||
suggest: protectedProcedure
|
||||
suggest: withPermission("ai", "read")
|
||||
.input(
|
||||
z.object({
|
||||
aiId: z.string(),
|
||||
@@ -338,7 +408,7 @@ ${input.logs}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
deploy: protectedProcedure
|
||||
deploy: withPermission("ai", "read")
|
||||
.input(deploySuggestionSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const environment = await findEnvironmentById(input.environmentId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
CLEANUP_CRON_JOB,
|
||||
checkGPUStatus,
|
||||
@@ -81,6 +82,32 @@ import {
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
|
||||
/**
|
||||
* Confines a caller-supplied Traefik file path to the Traefik configuration
|
||||
* directory (MAIN_TRAEFIK_PATH). The read/write Traefik file endpoints only
|
||||
* expose files listed under this directory, but the raw path was passed
|
||||
* straight to the filesystem helpers, letting a user with the `traefikFiles`
|
||||
* permission read or overwrite arbitrary files on the host (for example
|
||||
* `/etc/dokploy/.env` or `~/.ssh/authorized_keys`) via an absolute path or
|
||||
* `../` traversal. Reject anything that escapes the directory.
|
||||
*/
|
||||
const resolveTraefikFilePath = (
|
||||
filePath: string,
|
||||
serverId?: string | null,
|
||||
): string => {
|
||||
const { MAIN_TRAEFIK_PATH } = paths(!!serverId);
|
||||
const base = path.resolve(MAIN_TRAEFIK_PATH);
|
||||
const resolved = path.resolve(base, filePath);
|
||||
if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Invalid path: file must be inside the Traefik configuration directory",
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const settingsRouter = createTRPCRouter({
|
||||
getWebServerSettings: protectedProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
@@ -586,6 +613,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
await checkPermission(ctx, { traefikFiles: ["read"] });
|
||||
if (input?.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
const { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId);
|
||||
const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId);
|
||||
return result || [];
|
||||
@@ -598,8 +631,15 @@ export const settingsRouter = createTRPCRouter({
|
||||
.input(apiModifyTraefikConfig)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkPermission(ctx, { traefikFiles: ["write"] });
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
const safePath = resolveTraefikFilePath(input.path, input?.serverId);
|
||||
await writeTraefikConfigInPath(
|
||||
input.path,
|
||||
safePath,
|
||||
input.traefikConfig,
|
||||
input?.serverId,
|
||||
);
|
||||
@@ -624,7 +664,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
return readConfigInPath(input.path, input.serverId);
|
||||
const safePath = resolveTraefikFilePath(input.path, input.serverId);
|
||||
return readConfigInPath(safePath, input.serverId);
|
||||
}),
|
||||
getIp: protectedProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
|
||||
@@ -12,7 +12,10 @@ import { db } from "@dokploy/server/db";
|
||||
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 {
|
||||
checkPermission,
|
||||
resolvePermissions,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||
@@ -82,14 +85,14 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||
return createInnerTRPCContext({
|
||||
req,
|
||||
res,
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
session: session
|
||||
? {
|
||||
...session,
|
||||
activeOrganizationId: session.activeOrganizationId || "",
|
||||
}
|
||||
: null,
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
user: user
|
||||
? {
|
||||
...user,
|
||||
@@ -263,3 +266,25 @@ export const withPermission = <R extends Resource>(
|
||||
await checkPermission(ctx, { [resource]: [action] } as any);
|
||||
return next();
|
||||
});
|
||||
|
||||
export const withAnyPermission = <R extends Resource>(
|
||||
resource: R,
|
||||
actions: ActionOf<R>[],
|
||||
) =>
|
||||
protectedProcedure.use(async ({ ctx, next }) => {
|
||||
const permissions = await resolvePermissions(ctx);
|
||||
const resourcePerms = permissions[resource];
|
||||
|
||||
if (resourcePerms) {
|
||||
for (const action of actions) {
|
||||
if ((resourcePerms as Record<string, boolean>)[action as string]) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Permission denied",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,4 +95,5 @@ export type AuditResourceType =
|
||||
| "network"
|
||||
| "vaultProvider"
|
||||
| "dnsProvider"
|
||||
| "telemetryProvider";
|
||||
| "telemetryProvider"
|
||||
| "ai";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createAccessControl } from "better-auth/plugins/access";
|
||||
* The rest are Dokploy-specific resources.
|
||||
*
|
||||
* Enterprise-only resources (only assignable via custom roles):
|
||||
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring
|
||||
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring, ai
|
||||
*/
|
||||
export const statements = {
|
||||
// better-auth organization plugin defaults
|
||||
@@ -51,6 +51,7 @@ export const statements = {
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -79,6 +80,7 @@ export const enterpriseOnlyResources = new Set<string>([
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
"ai",
|
||||
]);
|
||||
|
||||
export const ac = createAccessControl(statements);
|
||||
@@ -121,6 +123,7 @@ export const ownerRole = ac.newRole({
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
telemetryProvider: ["read", "create", "delete"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -161,6 +164,7 @@ export const adminRole = ac.newRole({
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
telemetryProvider: ["read", "create", "delete"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -207,4 +211,5 @@ export const memberRole = ac.newRole({
|
||||
// Members need provider/secret names for env editor autocomplete; values are never exposed
|
||||
vaultProvider: ["read"],
|
||||
dnsProvider: [],
|
||||
ai: [],
|
||||
});
|
||||
|
||||
@@ -4,12 +4,22 @@ import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai";
|
||||
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { generateText, Output } from "ai";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
import { findServerById } from "./server";
|
||||
import { getWebServerSettings } from "./web-server-settings";
|
||||
|
||||
export const AI_SECRET_MASK = "********";
|
||||
|
||||
export const maskAiApiKey = <T extends { apiKey: string }>(settings: T): T => ({
|
||||
...settings,
|
||||
apiKey: AI_SECRET_MASK,
|
||||
});
|
||||
|
||||
export const mergeAiApiKey = (incoming: string, existing: string): string =>
|
||||
incoming === AI_SECRET_MASK ? existing : incoming;
|
||||
|
||||
interface SuggestionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -36,9 +46,12 @@ export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
||||
return aiSettings;
|
||||
};
|
||||
|
||||
export const getAiSettingById = async (aiId: string) => {
|
||||
export const getAiSettingById = async (
|
||||
aiId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const aiSetting = await db.query.ai.findFirst({
|
||||
where: eq(ai.aiId, aiId),
|
||||
where: and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)),
|
||||
});
|
||||
if (!aiSetting) {
|
||||
throw new TRPCError({
|
||||
@@ -95,11 +108,43 @@ export const saveCustomAiProviders = async (
|
||||
return providers;
|
||||
};
|
||||
|
||||
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
|
||||
export const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
|
||||
|
||||
export const saveAiSettings = async (organizationId: string, settings: any) => {
|
||||
export const resolveAiApiKey = async (opts: {
|
||||
apiKey: string;
|
||||
apiUrl: string;
|
||||
aiId: string | undefined;
|
||||
organizationId: string;
|
||||
authorize: () => Promise<boolean>;
|
||||
}): Promise<string> => {
|
||||
if (opts.apiKey !== AI_SECRET_MASK || !opts.aiId) return opts.apiKey;
|
||||
if (!(await opts.authorize())) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Permission denied",
|
||||
});
|
||||
}
|
||||
const stored = await getAiSettingById(opts.aiId, opts.organizationId);
|
||||
if (normalizeApiUrl(stored.apiUrl) !== normalizeApiUrl(opts.apiUrl)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "API URL does not match the stored configuration",
|
||||
});
|
||||
}
|
||||
return stored.apiKey;
|
||||
};
|
||||
|
||||
export const saveAiSettings = async (
|
||||
organizationId: string,
|
||||
settings: any,
|
||||
checkExistence = true,
|
||||
) => {
|
||||
const aiId = settings.aiId;
|
||||
|
||||
if (aiId && checkExistence) {
|
||||
await getAiSettingById(aiId, organizationId);
|
||||
}
|
||||
|
||||
if (settings.apiUrl) {
|
||||
const customProviders = await getCustomAiProviders(organizationId);
|
||||
if (customProviders.length > 0) {
|
||||
@@ -132,8 +177,13 @@ export const saveAiSettings = async (organizationId: string, settings: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteAiSettings = async (aiId: string) => {
|
||||
return db.delete(ai).where(eq(ai.aiId, aiId));
|
||||
export const deleteAiSettings = async (
|
||||
aiId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
return db
|
||||
.delete(ai)
|
||||
.where(and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)));
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -144,14 +194,14 @@ interface Props {
|
||||
}
|
||||
|
||||
export const suggestVariants = async ({
|
||||
organizationId: _organizationId,
|
||||
organizationId,
|
||||
aiId,
|
||||
input,
|
||||
serverId,
|
||||
}: Props) => {
|
||||
try {
|
||||
const aiSettings = await getAiSettingById(aiId);
|
||||
if (!aiSettings || !aiSettings.isEnabled) {
|
||||
const aiSettings = await getAiSettingById(aiId, organizationId);
|
||||
if (!aiSettings?.isEnabled) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "AI features are not enabled for this configuration",
|
||||
@@ -209,7 +259,6 @@ export const suggestVariants = async ({
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
|
||||
output: Output.object({ schema: fullSchema }),
|
||||
prompt: `
|
||||
Act as advanced DevOps engineer. Analyze the user's request and generate up to 3 deployment suggestions, each with a complete docker compose configuration.
|
||||
|
||||
@@ -27,6 +27,14 @@ const stripTrailingDot = (name: string) => name.replace(/\.$/, "");
|
||||
const ensureTrailingDot = (name: string) =>
|
||||
name.endsWith(".") ? name : `${name}.`;
|
||||
const stripZonePrefix = (id: string) => id.replace(/^\/hostedzone\//, "");
|
||||
// Route53 returns special characters in names as \ + three-digit octal code (e.g. * as \052).
|
||||
// Escaped dots (\056) and backslashes (\134) stay escaped so decoded names remain unique.
|
||||
const decodeRecordName = (name: string) =>
|
||||
name.replace(/\\([0-7]{3})/g, (match, code) =>
|
||||
code === "056" || code === "134"
|
||||
? match
|
||||
: String.fromCharCode(Number.parseInt(code, 8)),
|
||||
);
|
||||
|
||||
const buildRecordId = (type: string, name: string) =>
|
||||
`${type}:${stripTrailingDot(name)}`;
|
||||
@@ -61,7 +69,8 @@ const findExactRecordSet = async (
|
||||
if (
|
||||
candidate &&
|
||||
candidate.Type === type &&
|
||||
stripTrailingDot(candidate.Name ?? "") === stripTrailingDot(name)
|
||||
stripTrailingDot(decodeRecordName(candidate.Name ?? "")) ===
|
||||
stripTrailingDot(name)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
@@ -132,10 +141,11 @@ export const route53Client: DnsClient<Route53Config> = {
|
||||
if (!set.Name || !set.Type || !set.ResourceRecords?.length) {
|
||||
continue;
|
||||
}
|
||||
const name = decodeRecordName(set.Name);
|
||||
records.push({
|
||||
id: buildRecordId(set.Type, set.Name),
|
||||
id: buildRecordId(set.Type, name),
|
||||
type: set.Type,
|
||||
name: stripTrailingDot(set.Name),
|
||||
name: stripTrailingDot(name),
|
||||
content: set.ResourceRecords.map((r) => r.Value).join("\n"),
|
||||
ttl: set.TTL ?? 300,
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import { render } from "@react-email/components";
|
||||
import { format } from "date-fns";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
escapeHtml,
|
||||
formatTelegramErrorMessage,
|
||||
sendCustomNotification,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
@@ -201,7 +203,7 @@ export const sendBuildErrorNotifications = async ({
|
||||
|
||||
await sendTelegramNotification(
|
||||
telegram,
|
||||
`<b>⚠️ Build Failed</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Type:</b> ${applicationType}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`,
|
||||
`<b>⚠️ Build Failed</b>\n\n<b>Project:</b> ${escapeHtml(projectName)}\n<b>Application:</b> ${escapeHtml(applicationName)}\n<b>Type:</b> ${escapeHtml(applicationType)}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}\n\n<b>Error:</b>\n<pre>${formatTelegramErrorMessage(errorMessage)}</pre>`,
|
||||
inlineButton,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { render } from "@react-email/components";
|
||||
import { format } from "date-fns";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
escapeHtml,
|
||||
sendCustomNotification,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
@@ -214,7 +215,7 @@ export const sendBuildSuccessNotifications = async ({
|
||||
|
||||
await sendTelegramNotification(
|
||||
telegram,
|
||||
`<b>✅ Build Success</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Environment:</b> ${environmentName}\n<b>Type:</b> ${applicationType}\n<b>Date:</b> ${format(
|
||||
`<b>✅ Build Success</b>\n\n<b>Project:</b> ${escapeHtml(projectName)}\n<b>Application:</b> ${escapeHtml(applicationName)}\n<b>Environment:</b> ${escapeHtml(environmentName)}\n<b>Type:</b> ${escapeHtml(applicationType)}\n<b>Date:</b> ${format(
|
||||
date,
|
||||
"PP",
|
||||
)}\n<b>Time:</b> ${format(date, "pp")}`,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { render } from "@react-email/components";
|
||||
import { format } from "date-fns";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
escapeHtml,
|
||||
formatTelegramErrorMessage,
|
||||
sendCustomNotification,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
@@ -208,10 +210,10 @@ export const sendDatabaseBackupNotifications = async ({
|
||||
const statusEmoji = type === "success" ? "✅" : "❌";
|
||||
const typeStatus = type === "success" ? "Successful" : "Failed";
|
||||
const errorMsg = isError
|
||||
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
|
||||
? `\n\n<b>Error:</b>\n<pre>${formatTelegramErrorMessage(errorMessage)}</pre>`
|
||||
: "";
|
||||
|
||||
const messageText = `<b>${statusEmoji} Database Backup ${typeStatus}</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Type:</b> ${databaseType}\n<b>Database Name:</b> ${databaseName}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
|
||||
const messageText = `<b>${statusEmoji} Database Backup ${typeStatus}</b>\n\n<b>Project:</b> ${escapeHtml(projectName)}\n<b>Application:</b> ${escapeHtml(applicationName)}\n<b>Type:</b> ${escapeHtml(databaseType)}\n<b>Database Name:</b> ${escapeHtml(databaseName)}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
|
||||
|
||||
await sendTelegramNotification(telegram, messageText);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { render } from "@react-email/components";
|
||||
import { format } from "date-fns";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
escapeHtml,
|
||||
sendCustomNotification,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
@@ -143,7 +144,7 @@ export const sendDockerCleanupNotifications = async (
|
||||
if (telegram) {
|
||||
await sendTelegramNotification(
|
||||
telegram,
|
||||
`<b>✅ Docker Cleanup</b>\n\n<b>Message:</b> ${message}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}`,
|
||||
`<b>✅ Docker Cleanup</b>\n\n<b>Message:</b> ${escapeHtml(message)}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { render } from "@react-email/components";
|
||||
import { format } from "date-fns";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
escapeHtml,
|
||||
formatTelegramErrorMessage,
|
||||
sendCustomNotification,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
@@ -186,10 +188,10 @@ export const sendDokployBackupNotifications = async ({
|
||||
const statusEmoji = type === "success" ? "✅" : "❌";
|
||||
const typeStatus = type === "success" ? "Successful" : "Failed";
|
||||
const errorMsg = isError
|
||||
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
|
||||
? `\n\n<b>Error:</b>\n<pre>${formatTelegramErrorMessage(errorMessage)}</pre>`
|
||||
: "";
|
||||
const sizeInfo = backupSize
|
||||
? `\n<b>Backup Size:</b> ${backupSize}`
|
||||
? `\n<b>Backup Size:</b> ${escapeHtml(backupSize)}`
|
||||
: "";
|
||||
|
||||
const messageText = `<b>${statusEmoji} Dokploy Backup ${typeStatus}</b>\n\n<b>Backup Type:</b> Complete Dokploy Instance${sizeInfo}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { db } from "../../db";
|
||||
import { notifications } from "../../db/schema";
|
||||
import ServerThresholdEmail from "../../emails/emails/server-threshold";
|
||||
import {
|
||||
escapeHtml,
|
||||
sendCustomNotification,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
@@ -189,11 +190,11 @@ export const sendServerThresholdNotifications = async (
|
||||
telegram,
|
||||
`
|
||||
<b>⚠️ Server ${payload.Type} Alert</b>
|
||||
<b>Server Name:</b> ${payload.ServerName}
|
||||
<b>Server Name:</b> ${escapeHtml(payload.ServerName)}
|
||||
<b>Type:</b> ${payload.Type}
|
||||
<b>Current Value:</b> ${payload.Value.toFixed(2)}%
|
||||
<b>Threshold:</b> ${payload.Threshold.toFixed(2)}%
|
||||
<b>Message:</b> ${payload.Message}
|
||||
<b>Message:</b> ${escapeHtml(payload.Message)}
|
||||
<b>Time:</b> ${date.toLocaleString()}
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -101,6 +101,30 @@ export const sendDiscordNotification = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const escapeHtml = (s: string) =>
|
||||
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
export const formatTelegramErrorMessage = (errorMessage: string) => {
|
||||
const escaped = escapeHtml(errorMessage);
|
||||
const limit = 3000;
|
||||
if (escaped.length <= limit) return escaped;
|
||||
let end = limit;
|
||||
const ampIdx = escaped.lastIndexOf("&", end);
|
||||
if (ampIdx !== -1 && escaped.indexOf(";", ampIdx) >= end) {
|
||||
end = ampIdx;
|
||||
}
|
||||
if (
|
||||
end > 0 &&
|
||||
end < escaped.length &&
|
||||
isHighSurrogate(escaped.charCodeAt(end - 1))
|
||||
) {
|
||||
end--;
|
||||
}
|
||||
return `${escaped.substring(0, end)}…`;
|
||||
};
|
||||
|
||||
const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff;
|
||||
|
||||
export const sendTelegramNotification = async (
|
||||
connection: typeof telegram.$inferInsert,
|
||||
messageText: string,
|
||||
@@ -111,7 +135,7 @@ export const sendTelegramNotification = async (
|
||||
) => {
|
||||
try {
|
||||
const url = `https://api.telegram.org/bot${connection.botToken}/sendMessage`;
|
||||
await fetch(url, {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -125,8 +149,17 @@ export const sendTelegramNotification = async (
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(
|
||||
`Failed to send telegram notification: ${response.status} ${body}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw new Error(
|
||||
`Failed to send telegram notification ${err instanceof Error ? err.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { render } from "@react-email/components";
|
||||
import { format } from "date-fns";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
escapeHtml,
|
||||
formatTelegramErrorMessage,
|
||||
sendCustomNotification,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
@@ -223,13 +225,13 @@ export const sendVolumeBackupNotifications = async ({
|
||||
const statusEmoji = type === "success" ? "✅" : "❌";
|
||||
const typeStatus = type === "success" ? "Successful" : "Failed";
|
||||
const errorMsg = isError
|
||||
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
|
||||
? `\n\n<b>Error:</b>\n<pre>${formatTelegramErrorMessage(errorMessage)}</pre>`
|
||||
: "";
|
||||
const sizeInfo = backupSize
|
||||
? `\n<b>Backup Size:</b> ${backupSize}`
|
||||
? `\n<b>Backup Size:</b> ${escapeHtml(backupSize)}`
|
||||
: "";
|
||||
|
||||
const messageText = `<b>${statusEmoji} Volume Backup ${typeStatus}</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Volume Name:</b> ${volumeName}\n<b>Service Type:</b> ${serviceType}${sizeInfo}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
|
||||
const messageText = `<b>${statusEmoji} Volume Backup ${typeStatus}</b>\n\n<b>Project:</b> ${escapeHtml(projectName)}\n<b>Application:</b> ${escapeHtml(applicationName)}\n<b>Volume Name:</b> ${escapeHtml(volumeName)}\n<b>Service Type:</b> ${escapeHtml(serviceType)}${sizeInfo}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
|
||||
|
||||
await sendTelegramNotification(telegram, messageText);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export const pipeBetweenServers = async ({
|
||||
bytes += chunk.length;
|
||||
onProgress?.(bytes);
|
||||
});
|
||||
from.stdout.on("error", () => {});
|
||||
from.stdout.pipe(to.stdin);
|
||||
|
||||
const targetExit = to.exit.then((code) => {
|
||||
|
||||
Reference in New Issue
Block a user