feat(multi-server): add actions to the server

This commit is contained in:
Mauricio Siu
2024-09-21 00:06:41 -06:00
parent 0b22b694e6
commit 497d45129c
15 changed files with 684 additions and 134 deletions

View File

@@ -18,6 +18,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config"; import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
import { Loader2 } from "lucide-react";
const UpdateServerMiddlewareConfigSchema = z.object({ const UpdateServerMiddlewareConfigSchema = z.object({
traefikConfig: z.string(), traefikConfig: z.string(),
@@ -29,12 +30,18 @@ type UpdateServerMiddlewareConfig = z.infer<
interface Props { interface Props {
path: string; path: string;
serverId?: string;
} }
export const ShowTraefikFile = ({ path }: Props) => { export const ShowTraefikFile = ({ path, serverId }: Props) => {
const { data, refetch } = api.settings.readTraefikFile.useQuery( const {
data,
refetch,
isLoading: isLoadingFile,
} = api.settings.readTraefikFile.useQuery(
{ {
path, path,
serverId,
}, },
{ {
enabled: !!path, enabled: !!path,
@@ -54,11 +61,9 @@ export const ShowTraefikFile = ({ path }: Props) => {
}); });
useEffect(() => { useEffect(() => {
if (data) { form.reset({
form.reset({ traefikConfig: data || "",
traefikConfig: data || "", });
});
}
}, [form, form.reset, data]); }, [form, form.reset, data]);
const onSubmit = async (data: UpdateServerMiddlewareConfig) => { const onSubmit = async (data: UpdateServerMiddlewareConfig) => {
@@ -74,6 +79,7 @@ export const ShowTraefikFile = ({ path }: Props) => {
await mutateAsync({ await mutateAsync({
traefikConfig: data.traefikConfig, traefikConfig: data.traefikConfig,
path, path,
serverId,
}) })
.then(async () => { .then(async () => {
toast.success("Traefik config Updated"); toast.success("Traefik config Updated");
@@ -93,20 +99,28 @@ export const ShowTraefikFile = ({ path }: Props) => {
className="w-full relative z-[5]" className="w-full relative z-[5]"
> >
<div className="flex flex-col overflow-auto"> <div className="flex flex-col overflow-auto">
<FormField {isLoadingFile ? (
control={form.control} <div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
name="traefikConfig" <span className="text-muted-foreground text-lg font-medium">
render={({ field }) => ( Loading...
<FormItem className="relative"> </span>
<FormLabel>Traefik config</FormLabel> <Loader2 className="animate-spin size-8 text-muted-foreground" />
<FormDescription className="break-all"> </div>
{path} ) : (
</FormDescription> <FormField
<FormControl> control={form.control}
<CodeEditor name="traefikConfig"
lineWrapping render={({ field }) => (
wrapperClassName="h-[35rem] font-mono" <FormItem className="relative">
placeholder={`http: <FormLabel>Traefik config</FormLabel>
<FormDescription className="break-all">
{path}
</FormDescription>
<FormControl>
<CodeEditor
lineWrapping
wrapperClassName="h-[35rem] font-mono"
placeholder={`http:
routers: routers:
router-name: router-name:
rule: Host('domain.com') rule: Host('domain.com')
@@ -116,31 +130,36 @@ routers:
tls: false tls: false
middlewares: [] middlewares: []
`} `}
{...field} {...field}
/> />
</FormControl> </FormControl>
<pre> <pre>
<FormMessage /> <FormMessage />
</pre> </pre>
<div className="flex justify-end absolute z-50 right-6 top-8"> <div className="flex justify-end absolute z-50 right-6 top-8">
<Button <Button
className="shadow-sm" className="shadow-sm"
variant="secondary" variant="secondary"
type="button" type="button"
onClick={async () => { onClick={async () => {
setCanEdit(!canEdit); setCanEdit(!canEdit);
}} }}
> >
{canEdit ? "Unlock" : "Lock"} {canEdit ? "Unlock" : "Lock"}
</Button> </Button>
</div> </div>
</FormItem> </FormItem>
)} )}
/> />
)}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button isLoading={isLoading} disabled={canEdit} type="submit"> <Button
isLoading={isLoading}
disabled={canEdit || isLoading}
type="submit"
>
Update Update
</Button> </Button>
</div> </div>

View File

@@ -1,20 +1,38 @@
import React from "react"; import React from "react";
import { Tree } from "@/components/ui/file-tree"; import { Tree } from "@/components/ui/file-tree";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { FileIcon, Folder, Workflow } from "lucide-react"; import { FileIcon, Folder, Loader2, Workflow } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ShowTraefikFile } from "./show-traefik-file"; import { ShowTraefikFile } from "./show-traefik-file";
import { AlertBlock } from "@/components/shared/alert-block";
export const ShowTraefikSystem = () => { interface Props {
serverId?: string;
}
export const ShowTraefikSystem = ({ serverId }: Props) => {
const [file, setFile] = React.useState<null | string>(null); const [file, setFile] = React.useState<null | string>(null);
const { data: directories } = api.settings.readDirectories.useQuery(); const {
data: directories,
isLoading,
error,
isError,
} = api.settings.readDirectories.useQuery({
serverId,
});
return ( return (
<div className={cn("mt-6 md:grid gap-4")}> <div className={cn("mt-6 md:grid gap-4")}>
<div className="flex flex-col lg:flex-row gap-4 md:gap-10 w-full"> <div className="flex flex-col lg:flex-row gap-4 md:gap-10 w-full">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{isLoading && (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
Loading...
</span>
<Loader2 className="animate-spin size-8 text-muted-foreground" />
</div>
)}
{directories?.length === 0 && ( {directories?.length === 0 && (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]"> <div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium"> <span className="text-muted-foreground text-lg font-medium">
@@ -34,7 +52,7 @@ export const ShowTraefikSystem = () => {
/> />
<div className="w-full"> <div className="w-full">
{file ? ( {file ? (
<ShowTraefikFile path={file} /> <ShowTraefikFile path={file} serverId={serverId} />
) : ( ) : (
<div className="h-full w-full flex-col gap-2 flex items-center justify-center"> <div className="h-full w-full flex-col gap-2 flex items-center justify-center">
<span className="text-muted-foreground text-lg font-medium"> <span className="text-muted-foreground text-lg font-medium">

View File

@@ -49,13 +49,7 @@ export const SetupServer = ({ serverId }: Props) => {
}, },
); );
const { mutateAsync, isLoading } = api.server.setup.useMutation({ const { mutateAsync, isLoading } = api.server.setup.useMutation();
onMutate: async (variables) => {
console.log("Running....");
refetch();
// refetch();
},
});
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -99,7 +93,7 @@ export const SetupServer = ({ serverId }: Props) => {
serverId: server?.serverId || "", serverId: server?.serverId || "",
}) })
.then(async () => { .then(async () => {
// refetch(); refetch();
toast.success("Server setup successfully"); toast.success("Server setup successfully");
}) })
.catch(() => { .catch(() => {

View File

@@ -6,6 +6,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
@@ -26,11 +27,45 @@ import { TerminalModal } from "../web-server/terminal-modal";
import { AddServer } from "./add-server"; import { AddServer } from "./add-server";
import { SetupServer } from "./setup-server"; import { SetupServer } from "./setup-server";
import { UpdateServer } from "./update-server"; import { UpdateServer } from "./update-server";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { ShowModalLogs } from "../web-server/show-modal-logs";
export const ShowServers = () => { export const ShowServers = () => {
const { data, refetch } = api.server.all.useQuery(); const { data, refetch } = api.server.all.useQuery();
const { mutateAsync } = api.server.remove.useMutation(); const { mutateAsync } = api.server.remove.useMutation();
const { data: sshKeys } = api.sshKey.all.useQuery(); const { data: sshKeys } = api.sshKey.all.useQuery();
const { mutateAsync: toggleDashboard, isLoading: toggleDashboardIsLoading } =
api.settings.toggleDashboard.useMutation();
const {
mutateAsync: cleanDockerBuilder,
isLoading: cleanDockerBuilderIsLoading,
} = api.settings.cleanDockerBuilder.useMutation();
const {
mutateAsync: cleanUnusedImages,
isLoading: cleanUnusedImagesIsLoading,
} = api.settings.cleanUnusedImages.useMutation();
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
api.settings.reloadTraefik.useMutation();
const {
mutateAsync: cleanUnusedVolumes,
isLoading: cleanUnusedVolumesIsLoading,
} = api.settings.cleanUnusedVolumes.useMutation();
const {
mutateAsync: cleanStoppedContainers,
isLoading: cleanStoppedContainersIsLoading,
} = api.settings.cleanStoppedContainers.useMutation();
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
api.settings.cleanAll.useMutation();
const { data: haveTraefikDashboardPortEnabled, refetch: refetchDashboard } =
api.settings.haveTraefikDashboardPortEnabled.useQuery();
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="space-y-2 flex flex-row justify-between items-end"> <div className="space-y-2 flex flex-row justify-between items-end">
@@ -116,7 +151,17 @@ export const ShowServers = () => {
<TableCell className="text-right flex justify-end"> <TableCell className="text-right flex justify-end">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger
asChild
disabled={
cleanAllIsLoading ||
cleanDockerBuilderIsLoading ||
cleanUnusedImagesIsLoading ||
cleanUnusedVolumesIsLoading ||
cleanStoppedContainersIsLoading ||
reloadTraefikIsLoading
}
>
<Button variant="ghost" className="h-8 w-8 p-0"> <Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
@@ -128,6 +173,7 @@ export const ShowServers = () => {
<span>Enter the terminal</span> <span>Enter the terminal</span>
</TerminalModal> </TerminalModal>
<SetupServer serverId={server.serverId} /> <SetupServer serverId={server.serverId} />
<UpdateServer serverId={server.serverId} /> <UpdateServer serverId={server.serverId} />
<DialogAction <DialogAction
title={"Delete Server"} title={"Delete Server"}
@@ -154,6 +200,150 @@ export const ShowServers = () => {
Delete Server Delete Server
</DropdownMenuItem> </DropdownMenuItem>
</DialogAction> </DialogAction>
<DropdownMenuSeparator />
<DropdownMenuLabel>Traefik</DropdownMenuLabel>
<DropdownMenuItem
onClick={async () => {
await reloadTraefik({
serverId: server.serverId,
})
.then(async () => {
toast.success("Traefik Reloaded");
})
.catch(() => {
toast.error("Error to reload the traefik");
});
}}
>
<span>Reload</span>
</DropdownMenuItem>
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
<ShowModalLogs
appName="dokploy-traefik"
serverId={server.serverId}
>
<span>Watch logs</span>
</ShowModalLogs>
{/* <DropdownMenuItem
onClick={async () => {
await toggleDashboard({
enableDashboard:
!haveTraefikDashboardPortEnabled,
serverId: server.serverId,
})
.then(async () => {
toast.success(
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
);
refetchDashboard();
})
.catch(() => {
toast.error(
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
);
});
}}
className="w-full cursor-pointer space-x-3"
>
<span>
{haveTraefikDashboardPortEnabled
? "Disable"
: "Enable"}{" "}
Dashboard
</span>
</DropdownMenuItem> */}
<DropdownMenuSeparator />
<DropdownMenuLabel>Storage</DropdownMenuLabel>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanUnusedImages({
serverId: server?.serverId,
})
.then(async () => {
toast.success("Cleaned images");
})
.catch(() => {
toast.error("Error to clean images");
});
}}
>
<span>Clean unused images</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanUnusedVolumes({
serverId: server?.serverId,
})
.then(async () => {
toast.success("Cleaned volumes");
})
.catch(() => {
toast.error("Error to clean volumes");
});
}}
>
<span>Clean unused volumes</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanStoppedContainers({
serverId: server?.serverId,
})
.then(async () => {
toast.success("Stopped containers cleaned");
})
.catch(() => {
toast.error(
"Error to clean stopped containers",
);
});
}}
>
<span>Clean stopped containers</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanDockerBuilder({
serverId: server?.serverId,
})
.then(async () => {
toast.success("Cleaned Docker Builder");
})
.catch(() => {
toast.error(
"Error to clean Docker Builder",
);
});
}}
>
<span>Clean Docker Builder & System</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanAll({
serverId: server?.serverId,
})
.then(async () => {
toast.success("Cleaned all");
})
.catch(() => {
toast.error("Error to clean all");
});
}}
>
<span>Clean all</span>
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</TableCell> </TableCell>

View File

@@ -0,0 +1,48 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { FileTextIcon } from "lucide-react";
import { useState } from "react";
import { ShowTraefikSystem } from "../../file-system/show-traefik-system";
interface Props {
serverId: string;
}
export const ShowTraefikFileSystemModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Traefik File System
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl overflow-y-auto max-h-screen ">
<DialogHeader>
<div className="flex flex-col gap-1.5">
<DialogTitle className="flex items-center gap-2">
<FileTextIcon className="size-5" /> Traefik File System
</DialogTitle>
<p className="text-muted-foreground text-sm">
See all the files and directories of your traefik configuration
</p>
</div>
</DialogHeader>
<div id="hook-form-add-gitlab" className="grid w-full gap-1">
<ShowTraefikSystem serverId={serverId} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -33,12 +33,15 @@ type Schema = z.infer<typeof schema>;
interface Props { interface Props {
children?: React.ReactNode; children?: React.ReactNode;
serverId?: string;
} }
export const EditTraefikEnv = ({ children }: Props) => { export const EditTraefikEnv = ({ children, serverId }: Props) => {
const [canEdit, setCanEdit] = useState(true); const [canEdit, setCanEdit] = useState(true);
const { data } = api.settings.readTraefikEnv.useQuery(); const { data } = api.settings.readTraefikEnv.useQuery({
serverId,
});
const { mutateAsync, isLoading, error, isError } = const { mutateAsync, isLoading, error, isError } =
api.settings.writeTraefikEnv.useMutation(); api.settings.writeTraefikEnv.useMutation();

View File

@@ -36,12 +36,14 @@ export const DockerLogsId = dynamic(
interface Props { interface Props {
appName: string; appName: string;
children?: React.ReactNode; children?: React.ReactNode;
serverId?: string;
} }
export const ShowModalLogs = ({ appName, children }: Props) => { export const ShowModalLogs = ({ appName, children, serverId }: Props) => {
const { data, isLoading } = api.docker.getContainersByAppLabel.useQuery( const { data, isLoading } = api.docker.getContainersByAppLabel.useQuery(
{ {
appName, appName,
serverId,
}, },
{ {
enabled: !!appName, enabled: !!appName,
@@ -96,7 +98,11 @@ export const ShowModalLogs = ({ appName, children }: Props) => {
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<DockerLogsId id="terminal" containerId={containerId || ""} /> <DockerLogsId
id="terminal"
containerId={containerId || ""}
serverId={serverId}
/>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -55,9 +55,10 @@ export const dockerRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
appName: z.string().min(1), appName: z.string().min(1),
serverId: z.string().optional(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input }) => {
return await getContainersByAppLabel(input.appName); return await getContainersByAppLabel(input.appName, input.serverId);
}), }),
}); });

View File

@@ -6,6 +6,7 @@ import {
apiReadStatsLogs, apiReadStatsLogs,
apiReadTraefikConfig, apiReadTraefikConfig,
apiSaveSSHKey, apiSaveSSHKey,
apiStorage,
apiTraefikConfig, apiTraefikConfig,
apiUpdateDockerCleanup, apiUpdateDockerCleanup,
} from "@/server/db/schema"; } from "@/server/db/schema";
@@ -20,11 +21,13 @@ import {
cleanUpUnusedVolumes, cleanUpUnusedVolumes,
prepareEnvironmentVariables, prepareEnvironmentVariables,
startService, startService,
startServiceRemote,
stopService, stopService,
stopServiceRemote,
} from "@/server/utils/docker/utils"; } from "@/server/utils/docker/utils";
import { recreateDirectory } from "@/server/utils/filesystem/directory"; import { recreateDirectory } from "@/server/utils/filesystem/directory";
import { sendDockerCleanupNotifications } from "@/server/utils/notifications/docker-cleanup"; import { sendDockerCleanupNotifications } from "@/server/utils/notifications/docker-cleanup";
import { execAsync } from "@/server/utils/process/execAsync"; import { execAsync, execAsyncRemote } from "@/server/utils/process/execAsync";
import { spawnAsync } from "@/server/utils/process/spawnAsync"; import { spawnAsync } from "@/server/utils/process/spawnAsync";
import { import {
readConfig, readConfig,
@@ -63,16 +66,23 @@ export const settingsRouter = createTRPCRouter({
await execAsync(`docker service update --force ${stdout.trim()}`); await execAsync(`docker service update --force ${stdout.trim()}`);
return true; return true;
}), }),
reloadTraefik: adminProcedure.mutation(async () => { reloadTraefik: adminProcedure
try { .input(apiStorage)
await stopService("dokploy-traefik"); .mutation(async ({ input }) => {
await startService("dokploy-traefik"); try {
} catch (err) { if (input?.serverId) {
console.error(err); await stopServiceRemote(input.serverId, "dokploy-traefik");
} await startServiceRemote(input.serverId, "dokploy-traefik");
} else {
await stopService("dokploy-traefik");
await startService("dokploy-traefik");
}
} catch (err) {
console.error(err);
}
return true; return true;
}), }),
toggleDashboard: adminProcedure toggleDashboard: adminProcedure
.input(apiEnableDashboard) .input(apiEnableDashboard)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -82,31 +92,42 @@ export const settingsRouter = createTRPCRouter({
return true; return true;
}), }),
cleanUnusedImages: adminProcedure.mutation(async () => { cleanUnusedImages: adminProcedure
await cleanUpUnusedImages(); .input(apiStorage)
return true; .mutation(async ({ input }) => {
}), await cleanUpUnusedImages(input?.serverId);
cleanUnusedVolumes: adminProcedure.mutation(async () => { return true;
await cleanUpUnusedVolumes(); }),
return true; cleanUnusedVolumes: adminProcedure
}), .input(apiStorage)
cleanStoppedContainers: adminProcedure.mutation(async () => { .mutation(async ({ input }) => {
await cleanStoppedContainers(); await cleanUpUnusedVolumes(input?.serverId);
return true; return true;
}), }),
cleanDockerBuilder: adminProcedure.mutation(async () => { cleanStoppedContainers: adminProcedure
await cleanUpDockerBuilder(); .input(apiStorage)
}), .mutation(async ({ input }) => {
cleanDockerPrune: adminProcedure.mutation(async () => { await cleanStoppedContainers(input?.serverId);
await cleanUpSystemPrune(); return true;
await cleanUpDockerBuilder(); }),
cleanDockerBuilder: adminProcedure
.input(apiStorage)
.mutation(async ({ input }) => {
await cleanUpDockerBuilder(input?.serverId);
}),
cleanDockerPrune: adminProcedure
.input(apiStorage)
.mutation(async ({ input }) => {
await cleanUpSystemPrune(input?.serverId);
await cleanUpDockerBuilder(input?.serverId);
return true; return true;
}), }),
cleanAll: adminProcedure.mutation(async () => { cleanAll: adminProcedure.input(apiStorage).mutation(async ({ input }) => {
await cleanUpUnusedImages(); await cleanUpUnusedImages(input?.serverId);
await cleanUpDockerBuilder(); await cleanStoppedContainers(input?.serverId);
await cleanUpSystemPrune(); await cleanUpDockerBuilder(input?.serverId);
await cleanUpSystemPrune(input?.serverId);
return true; return true;
}), }),
@@ -230,18 +251,20 @@ export const settingsRouter = createTRPCRouter({
getDokployVersion: adminProcedure.query(() => { getDokployVersion: adminProcedure.query(() => {
return getDokployVersion(); return getDokployVersion();
}), }),
readDirectories: protectedProcedure.query(async ({ ctx }) => { readDirectories: protectedProcedure
if (ctx.user.rol === "user") { .input(apiStorage)
const canAccess = await canAccessToTraefikFiles(ctx.user.authId); .query(async ({ ctx, input }) => {
if (ctx.user.rol === "user") {
const canAccess = await canAccessToTraefikFiles(ctx.user.authId);
if (!canAccess) { if (!canAccess) {
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
}
} }
} const { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId);
const { MAIN_TRAEFIK_PATH } = paths(); const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId);
const result = readDirectory(MAIN_TRAEFIK_PATH); return result || [];
return result || []; }),
}),
updateTraefikFile: protectedProcedure updateTraefikFile: protectedProcedure
.input(apiModifyTraefikConfig) .input(apiModifyTraefikConfig)
@@ -253,7 +276,11 @@ export const settingsRouter = createTRPCRouter({
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
} }
} }
writeTraefikConfigInPath(input.path, input.traefikConfig); await writeTraefikConfigInPath(
input.path,
input.traefikConfig,
input?.serverId,
);
return true; return true;
}), }),
@@ -267,7 +294,7 @@ export const settingsRouter = createTRPCRouter({
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
} }
} }
return readConfigInPath(input.path); return readConfigInPath(input.path, input.serverId);
}), }),
getIp: protectedProcedure.query(async () => { getIp: protectedProcedure.query(async () => {
const admin = await findAdmin(); const admin = await findAdmin();
@@ -324,16 +351,20 @@ export const settingsRouter = createTRPCRouter({
return openApiDocument; return openApiDocument;
}, },
), ),
readTraefikEnv: adminProcedure.query(async () => { readTraefikEnv: adminProcedure.input(apiStorage).query(async ({ input }) => {
const { stdout } = await execAsync( const command =
"docker service inspect --format='{{range .Spec.TaskTemplate.ContainerSpec.Env}}{{println .}}{{end}}' dokploy-traefik", "docker service inspect --format='{{range .Spec.TaskTemplate.ContainerSpec.Env}}{{println .}}{{end}}' dokploy-traefik";
);
return stdout.trim(); if (input?.serverId) {
const result = await execAsyncRemote(input.serverId, command);
return result.stdout.trim();
}
const result = await execAsync(command);
return result.stdout.trim();
}), }),
writeTraefikEnv: adminProcedure writeTraefikEnv: adminProcedure
.input(z.object({ env: z.string() })) .input(z.object({ env: z.string(), serverId: z.string().optional() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const envs = prepareEnvironmentVariables(input.env); const envs = prepareEnvironmentVariables(input.env);
await initializeTraefik({ await initializeTraefik({

View File

@@ -126,12 +126,25 @@ export const getContainersByAppNameMatch = async (
return []; return [];
}; };
export const getContainersByAppLabel = async (appName: string) => { export const getContainersByAppLabel = async (
appName: string,
serverId?: string,
) => {
try { try {
const { stdout, stderr } = await execAsync( let stdout = "";
`docker ps --filter "label=com.docker.swarm.service.name=${appName}" --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}}'`, let stderr = "";
);
const command = `docker ps --filter "label=com.docker.swarm.service.name=${appName}" --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}}'`;
console.log(command);
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
stderr = result.stderr;
} else {
const result = await execAsync(command);
stdout = result.stdout;
stderr = result.stderr;
}
if (stderr) { if (stderr) {
console.error(`Error: ${stderr}`); console.error(`Error: ${stderr}`);
return; return;

View File

@@ -3,6 +3,7 @@ import { join } from "node:path";
import { docker } from "@/server/constants"; import { docker } from "@/server/constants";
import { getServiceContainer } from "@/server/utils/docker/utils"; import { getServiceContainer } from "@/server/utils/docker/utils";
import packageInfo from "../../../package.json"; import packageInfo from "../../../package.json";
import { execAsyncRemote } from "@/server/utils/process/execAsync";
const updateIsAvailable = async () => { const updateIsAvailable = async () => {
try { try {
@@ -44,8 +45,66 @@ interface TreeDataItem {
children?: TreeDataItem[]; children?: TreeDataItem[];
} }
export const readDirectory = (dirPath: string): TreeDataItem[] => { export const readDirectory = async (
dirPath: string,
serverId?: string,
): Promise<TreeDataItem[]> => {
if (serverId) {
const { stdout } = await execAsyncRemote(
serverId,
`
process_items() {
local parent_dir="$1"
local __resultvar=$2
local items_json=""
local first=true
for item in "$parent_dir"/*; do
[ -e "$item" ] || continue
process_item "$item" item_json
if [ "$first" = true ]; then
first=false
items_json="$item_json"
else
items_json="$items_json,$item_json"
fi
done
eval $__resultvar="'[$items_json]'"
}
process_item() {
local item_path="$1"
local __resultvar=$2
local item_name=$(basename "$item_path")
local escaped_name=$(echo "$item_name" | sed 's/"/\\"/g')
local escaped_path=$(echo "$item_path" | sed 's/"/\\"/g')
if [ -d "$item_path" ]; then
# Is directory
process_items "$item_path" children_json
local json='{"id":"'"$escaped_path"'","name":"'"$escaped_name"'","type":"directory","children":'"$children_json"'}'
else
# Is file
local json='{"id":"'"$escaped_path"'","name":"'"$escaped_name"'","type":"file"}'
fi
eval $__resultvar="'$json'"
}
root_dir=${dirPath}
process_items "$root_dir" json_output
echo "$json_output"
`,
);
const result = JSON.parse(stdout);
return result;
}
const items = readdirSync(dirPath, { withFileTypes: true }); const items = readdirSync(dirPath, { withFileTypes: true });
return items.map((item) => { return items.map((item) => {
const fullPath = join(dirPath, item.name); const fullPath = join(dirPath, item.name);
if (item.isDirectory()) { if (item.isDirectory()) {
@@ -61,5 +120,5 @@ export const readDirectory = (dirPath: string): TreeDataItem[] => {
name: item.name, name: item.name,
type: "file", type: "file",
}; };
}); }) as unknown as Promise<TreeDataItem[]>;
}; };

View File

@@ -72,15 +72,24 @@ export const apiTraefikConfig = z.object({
export const apiModifyTraefikConfig = z.object({ export const apiModifyTraefikConfig = z.object({
path: z.string().min(1), path: z.string().min(1),
traefikConfig: z.string().min(1), traefikConfig: z.string().min(1),
serverId: z.string().optional(),
}); });
export const apiReadTraefikConfig = z.object({ export const apiReadTraefikConfig = z.object({
path: z.string().min(1), path: z.string().min(1),
serverId: z.string().optional(),
}); });
export const apiEnableDashboard = z.object({ export const apiEnableDashboard = z.object({
enableDashboard: z.boolean().optional(), enableDashboard: z.boolean().optional(),
serverId: z.string().optional(),
}); });
export const apiStorage = z
.object({
serverId: z.string().optional(),
})
.optional();
export const apiReadStatsLogs = z.object({ export const apiReadStatsLogs = z.object({
page: z page: z
.object({ .object({

View File

@@ -149,27 +149,39 @@ export const getContainerByName = (name: string): Promise<ContainerInfo> => {
}); });
}); });
}; };
export const cleanUpUnusedImages = async () => { export const cleanUpUnusedImages = async (serverId?: string) => {
try { try {
await execAsync("docker image prune --all --force"); if (serverId) {
await execAsyncRemote(serverId, "docker image prune --all --force");
} else {
await execAsync("docker image prune --all --force");
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw error; throw error;
} }
}; };
export const cleanStoppedContainers = async () => { export const cleanStoppedContainers = async (serverId?: string) => {
try { try {
await execAsync("docker container prune --force"); if (serverId) {
await execAsyncRemote(serverId, "docker container prune --force");
} else {
await execAsync("docker container prune --force");
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw error; throw error;
} }
}; };
export const cleanUpUnusedVolumes = async () => { export const cleanUpUnusedVolumes = async (serverId?: string) => {
try { try {
await execAsync("docker volume prune --all --force"); if (serverId) {
await execAsyncRemote(serverId, "docker volume prune --all --force");
} else {
await execAsync("docker volume prune --all --force");
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw error; throw error;
@@ -193,12 +205,23 @@ export const cleanUpInactiveContainers = async () => {
} }
}; };
export const cleanUpDockerBuilder = async () => { export const cleanUpDockerBuilder = async (serverId?: string) => {
await execAsync("docker builder prune --all --force"); if (serverId) {
await execAsyncRemote(serverId, "docker builder prune --all --force");
} else {
await execAsync("docker builder prune --all --force");
}
}; };
export const cleanUpSystemPrune = async () => { export const cleanUpSystemPrune = async (serverId?: string) => {
await execAsync("docker system prune --all --force --volumes"); if (serverId) {
await execAsyncRemote(
serverId,
"docker system prune --all --force --volumes",
);
} else {
await execAsync("docker system prune --all --force --volumes");
}
}; };
export const startService = async (appName: string) => { export const startService = async (appName: string) => {

View File

@@ -5,6 +5,7 @@ import { paths } from "@/server/constants";
import { dump, load } from "js-yaml"; import { dump, load } from "js-yaml";
import { execAsyncRemote } from "../process/execAsync"; import { execAsyncRemote } from "../process/execAsync";
import type { FileConfig, HttpLoadBalancerService } from "./file-types"; import type { FileConfig, HttpLoadBalancerService } from "./file-types";
import { encodeBase64 } from "../docker/utils";
export const createTraefikConfig = (appName: string) => { export const createTraefikConfig = (appName: string) => {
const defaultPort = 3000; const defaultPort = 3000;
@@ -146,8 +147,14 @@ export const readMonitoringConfig = () => {
return null; return null;
}; };
export const readConfigInPath = (pathFile: string) => { export const readConfigInPath = async (pathFile: string, serverId?: string) => {
const configPath = path.join(pathFile); const configPath = path.join(pathFile);
if (serverId) {
const { stdout } = await execAsyncRemote(serverId, `cat ${configPath}`);
if (!stdout) return null;
return stdout;
}
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
const yamlStr = fs.readFileSync(configPath, "utf8"); const yamlStr = fs.readFileSync(configPath, "utf8");
return yamlStr; return yamlStr;
@@ -179,12 +186,22 @@ export const writeConfigRemote = async (
} }
}; };
export const writeTraefikConfigInPath = ( export const writeTraefikConfigInPath = async (
pathFile: string, pathFile: string,
traefikConfig: string, traefikConfig: string,
serverId?: string,
) => { ) => {
try { try {
const configPath = path.join(pathFile); const configPath = path.join(pathFile);
if (serverId) {
const encoded = encodeBase64(traefikConfig);
await execAsyncRemote(
serverId,
`echo "${encoded}" | base64 -d > "${configPath}"`,
);
} else {
fs.writeFileSync(configPath, traefikConfig, "utf8");
}
fs.writeFileSync(configPath, traefikConfig, "utf8"); fs.writeFileSync(configPath, traefikConfig, "utf8");
} catch (e) { } catch (e) {
console.error("Error saving the YAML config file:", e); console.error("Error saving the YAML config file:", e);

View File

@@ -3,6 +3,9 @@ import { spawn } from "node-pty";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { validateWebSocketRequest } from "../auth/auth"; import { validateWebSocketRequest } from "../auth/auth";
import { getShell } from "./utils"; import { getShell } from "./utils";
import { Client } from "ssh2";
import { readSSHKey } from "../utils/filesystem/ssh";
import { findServerById } from "../api/services/server";
export const setupDockerContainerTerminalWebSocketServer = ( export const setupDockerContainerTerminalWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>, server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -44,7 +47,123 @@ export const setupDockerContainerTerminalWebSocketServer = (
} }
try { try {
if (serverId) { if (serverId) {
// const server = await findServerById(serverId); const server = await findServerById(serverId);
if (!server.sshKeyId)
throw new Error("No SSH key available for this server");
const keys = await readSSHKey(server.sshKeyId);
const conn = new Client();
let stdout = "";
let stderr = "";
// conn
// .once("ready", () => {
// console.log("Client :: ready");
// conn.exec(
// `docker run -i ${containerId} ${activeWay}`,
// (err, stream) => {
// if (err) throw err;
// stream
// .on("close", (code: number, signal: string) => {
// console.log(
// `Stream :: close :: code: ${code}, signal: ${signal}`,
// );
// conn.end();
// })
// .on("data", (data: string) => {
// stdout += data.toString();
// ws.send(data.toString());
// })
// .stderr.on("data", (data) => {
// stderr += data.toString();
// ws.send(data.toString());
// console.error("Error: ", data.toString());
// });
// // Maneja la entrada de comandos desde WebSocket
// ws.on("message", (message) => {
// try {
// let command: string | Buffer[] | Buffer | ArrayBuffer;
// if (Buffer.isBuffer(message)) {
// command = message.toString("utf8");
// } else {
// command = message;
// }
// stream.write(command.toString());
// } catch (error) {
// // @ts-ignore
// const errorMessage = error?.message as unknown as string;
// ws.send(errorMessage);
// }
// });
// // Cuando se cierra la conexión WebSocket
// ws.on("close", () => {
// stream.end();
// });
// },
// );
// })p
// .connect({
// host: server.ipAddress,
// port: server.port,
// username: server.username,
// privateKey: keys.privateKey,
// timeout: 99999,
// });
// conn
// .once("ready", () => {
// console.log("Client :: ready");
// conn.shell((err, stream) => {
// if (err) throw err;
// stream
// .on("close", (code: number, signal: string) => {
// console.log(
// `Stream :: close :: code: ${code}, signal: ${signal}`,
// );
// conn.end();
// })
// .on("data", (data: string) => {
// stdout += data.toString();
// ws.send(data.toString());
// })
// .stderr.on("data", (data) => {
// stderr += data.toString();
// ws.send(data.toString());
// console.error("Error: ", data.toString());
// });
// stream.write(`docker exec -it ${containerId} ${activeWay}\n`);
// // Maneja la entrada de comandos desde WebSocket
// ws.on("message", (message) => {
// try {
// let command: string | Buffer[] | Buffer | ArrayBuffer;
// if (Buffer.isBuffer(message)) {
// command = message.toString("utf8");
// } else {
// command = message;
// }
// stream.write(command.toString());
// } catch (error) {
// // @ts-ignore
// const errorMessage = error?.message as unknown as string;
// ws.send(errorMessage);
// }
// });
// // Cuando se cierra la conexión WebSocket
// ws.on("close", () => {
// stream.end();
// });
// });
// })
// .connect({
// host: server.ipAddress,
// port: server.port,
// username: server.username,
// privateKey: keys.privateKey,
// timeout: 99999,
// });
} else { } else {
const shell = getShell(); const shell = getShell();
const ptyProcess = spawn( const ptyProcess = spawn(