feat: add web UI file upload to Docker containers

This commit is contained in:
HarikrishnanD
2025-11-05 11:55:40 +05:30
parent 1ce153371a
commit c5eb31ab90
5 changed files with 335 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ import {
import { ShowContainerConfig } from "../config/show-container-config";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
import { UploadFileModal } from "../upload/upload-file-modal";
import type { Container } from "./show-containers";
export const columns: ColumnDef<Container>[] = [
@@ -127,6 +128,12 @@ export const columns: ColumnDef<Container>[] = [
>
Terminal
</DockerTerminalModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Upload File
</UploadFileModal>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -0,0 +1,197 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Upload } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Dropzone } from "@/components/ui/dropzone";
import { api } from "@/utils/api";
import {
uploadFileToContainerSchema,
type UploadFileToContainer,
} from "@/utils/schema";
interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
}
export const UploadFileModal = ({
children,
containerId,
serverId,
}: Props) => {
const [open, setOpen] = useState(false);
const { mutateAsync: uploadFile, isLoading } =
api.docker.uploadFileToContainer.useMutation({
onSuccess: () => {
toast.success("File uploaded successfully");
setOpen(false);
form.reset();
},
onError: (error) => {
toast.error(
error.message || "Failed to upload file to container",
);
},
});
const form = useForm<UploadFileToContainer>({
resolver: zodResolver(uploadFileToContainerSchema),
defaultValues: {
containerId,
destinationPath: "/",
serverId: serverId || undefined,
},
});
const file = form.watch("file");
const onSubmit = async (values: UploadFileToContainer) => {
if (!values.file) {
toast.error("Please select a file to upload");
return;
}
const formData = new FormData();
formData.append("containerId", values.containerId);
formData.append("file", values.file);
formData.append("destinationPath", values.destinationPath);
if (values.serverId) {
formData.append("serverId", values.serverId);
}
await uploadFile(formData);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload File to Container
</DialogTitle>
<DialogDescription>
Upload a file directly into the container's filesystem
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="destinationPath"
render={({ field }) => (
<FormItem>
<FormLabel>Destination Path</FormLabel>
<FormControl>
<Input
{...field}
placeholder="/path/to/file"
className="font-mono"
/>
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
Enter the full path where the file should be
uploaded in the container (e.g., /app/config.json)
</p>
</FormItem>
)}
/>
<FormField
control={form.control}
name="file"
render={({ field }) => (
<FormItem>
<FormLabel>File</FormLabel>
<FormControl>
<Dropzone
{...field}
dropMessage="Drop file here or click to browse"
onChange={(files) => {
if (files && files.length > 0) {
field.onChange(files[0]);
} else {
field.onChange(null);
}
}}
/>
</FormControl>
<FormMessage />
{file instanceof File && (
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
<span className="text-sm text-muted-foreground flex-1">
{file.name} ({(file.size / 1024).toFixed(2)} KB)
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange(null)}
>
Remove
</Button>
</div>
)}
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
isLoading={isLoading}
disabled={!file || isLoading}
>
Upload File
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -7,10 +7,12 @@ import {
getContainersByAppNameMatch,
getServiceContainersByAppName,
getStackContainersByAppName,
uploadFileToContainer,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure, uploadProcedure } from "../trpc";
import { uploadFileToContainerSchema } from "@/utils/schema";
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
@@ -143,4 +145,38 @@ export const dockerRouter = createTRPCRouter({
}
return await getServiceContainersByAppName(input.appName, input.serverId);
}),
uploadFileToContainer: protectedProcedure
.use(uploadProcedure)
.input(uploadFileToContainerSchema)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const file = input.file;
if (!(file instanceof File)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid file provided",
});
}
// Convert File to Buffer
const arrayBuffer = await file.arrayBuffer();
const fileBuffer = Buffer.from(arrayBuffer);
await uploadFileToContainer(
input.containerId,
fileBuffer,
file.name,
input.destinationPath,
input.serverId || null,
);
return { success: true, message: "File uploaded successfully" };
}),
});

View File

@@ -17,3 +17,12 @@ export const uploadFileSchema = zfd.formData({
});
export type UploadFile = z.infer<typeof uploadFileSchema>;
export const uploadFileToContainerSchema = zfd.formData({
containerId: z.string().min(1).regex(/^[a-zA-Z0-9.\-_]+$/, "Invalid container ID"),
file: zfd.file(),
destinationPath: z.string().min(1),
serverId: z.string().optional(),
});
export type UploadFileToContainer = z.infer<typeof uploadFileToContainerSchema>;

View File

@@ -1,3 +1,6 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
execAsync,
execAsyncRemote,
@@ -472,3 +475,85 @@ export const getApplicationInfo = async (
return appArray;
} catch {}
};
export const uploadFileToContainer = async (
containerId: string,
fileBuffer: Buffer,
fileName: string,
destinationPath: string,
serverId?: string | null,
): Promise<void> => {
const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
if (!containerIdRegex.test(containerId)) {
throw new Error("Invalid container ID");
}
// Ensure destination path starts with /
const normalizedPath = destinationPath.startsWith("/")
? destinationPath
: `/${destinationPath}`;
if (serverId) {
// Remote server: transfer file via base64 encoding using heredoc
const base64Content = fileBuffer.toString("base64");
const tempFileName = `dokploy-upload-${Date.now()}-${fileName.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
const tempPath = `/tmp/${tempFileName}`;
try {
// Create temp directory and write file on remote server using heredoc
// This handles large files and special characters better than echo
const writeCommand = `cat << 'EOF' | base64 -d > "${tempPath}"
${base64Content}
EOF
`;
await execAsyncRemote(serverId, writeCommand);
// Copy file into container
const copyCommand = `docker cp "${tempPath}" "${containerId}:${normalizedPath}"`;
await execAsyncRemote(serverId, copyCommand);
// Clean up temp file
const cleanupCommand = `rm -f "${tempPath}"`;
await execAsyncRemote(serverId, cleanupCommand);
} catch (error) {
// Try to clean up on error
try {
await execAsyncRemote(serverId, `rm -f "${tempPath}"`);
} catch {
// Ignore cleanup errors
}
throw new Error(
`Failed to upload file to container: ${error instanceof Error ? error.message : String(error)}`,
);
}
} else {
// Local server: use temp directory
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-upload-"));
const tempFilePath = join(tempDir, fileName);
try {
// Write file to temp directory
await writeFile(tempFilePath, fileBuffer);
// Copy file into container
const copyCommand = `docker cp "${tempFilePath}" "${containerId}:${normalizedPath}"`;
await execAsync(copyCommand);
// Clean up temp directory
await rm(tempFilePath, { force: true });
await rm(tempDir, { recursive: true, force: true });
} catch (error) {
// Try to clean up on error
try {
await rm(tempFilePath, { force: true });
await rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
throw new Error(
`Failed to upload file to container: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
};