mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-07 20:45:17 +00:00
feat: add volumes section and file explorer for containers and volumes
This commit is contained in:
@@ -0,0 +1,401 @@
|
|||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
File,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
serverId?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
asDropdownItem?: boolean;
|
||||||
|
} & (
|
||||||
|
| { containerId: string; volumeName?: undefined }
|
||||||
|
| { volumeName: string; containerId?: undefined }
|
||||||
|
);
|
||||||
|
|
||||||
|
const joinPath = (base: string, name: string) =>
|
||||||
|
base === "/" ? `/${name}` : `${base}/${name}`;
|
||||||
|
|
||||||
|
const decodeBase64 = (base64: string) => {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilesExplorerModal = ({
|
||||||
|
containerId,
|
||||||
|
volumeName,
|
||||||
|
serverId,
|
||||||
|
children,
|
||||||
|
asDropdownItem = true,
|
||||||
|
}: Props) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [path, setPath] = useState("/");
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
const [editorContent, setEditorContent] = useState("");
|
||||||
|
|
||||||
|
const isVolume = !!volumeName;
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const containerEntries = api.docker.listContainerFiles.useQuery(
|
||||||
|
{ containerId: containerId ?? "", path, serverId },
|
||||||
|
{ enabled: open && !isVolume, retry: false },
|
||||||
|
);
|
||||||
|
const volumeEntries = api.dockerVolume.listVolumeFiles.useQuery(
|
||||||
|
{ volumeName: volumeName ?? "", path, serverId },
|
||||||
|
{ enabled: open && isVolume, retry: false },
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
data: entries,
|
||||||
|
isLoading: isLoadingEntries,
|
||||||
|
error: entriesError,
|
||||||
|
refetch: refetchEntries,
|
||||||
|
isRefetching,
|
||||||
|
} = isVolume ? volumeEntries : containerEntries;
|
||||||
|
|
||||||
|
const containerFile = api.docker.readContainerFile.useQuery(
|
||||||
|
{ containerId: containerId ?? "", path: selectedFile ?? "/", serverId },
|
||||||
|
{ enabled: open && !isVolume && !!selectedFile, retry: false },
|
||||||
|
);
|
||||||
|
const volumeFile = api.dockerVolume.readVolumeFile.useQuery(
|
||||||
|
{ volumeName: volumeName ?? "", path: selectedFile ?? "/", serverId },
|
||||||
|
{ enabled: open && isVolume && !!selectedFile, retry: false },
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
data: file,
|
||||||
|
isLoading: isLoadingFile,
|
||||||
|
error: fileError,
|
||||||
|
refetch: refetchFile,
|
||||||
|
} = isVolume ? volumeFile : containerFile;
|
||||||
|
|
||||||
|
const fileBytes = useMemo(
|
||||||
|
() => (file ? decodeBase64(file.content) : null),
|
||||||
|
[file],
|
||||||
|
);
|
||||||
|
const isBinary = useMemo(
|
||||||
|
() => !!fileBytes?.some((byte) => byte === 0),
|
||||||
|
[fileBytes],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fileBytes && !isBinary) {
|
||||||
|
setEditorContent(new TextDecoder().decode(fileBytes));
|
||||||
|
}
|
||||||
|
}, [fileBytes, isBinary]);
|
||||||
|
|
||||||
|
const writeContainerFile = api.docker.writeContainerFile.useMutation();
|
||||||
|
const writeVolumeFile = api.dockerVolume.writeVolumeFile.useMutation();
|
||||||
|
const deleteContainerFile = api.docker.deleteContainerFile.useMutation();
|
||||||
|
const deleteVolumeFile = api.dockerVolume.deleteVolumeFile.useMutation();
|
||||||
|
|
||||||
|
const isSaving = writeContainerFile.isPending || writeVolumeFile.isPending;
|
||||||
|
const isDeleting =
|
||||||
|
deleteContainerFile.isPending || deleteVolumeFile.isPending;
|
||||||
|
|
||||||
|
const saveFile = async () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
try {
|
||||||
|
if (isVolume) {
|
||||||
|
await writeVolumeFile.mutateAsync({
|
||||||
|
volumeName,
|
||||||
|
path: selectedFile,
|
||||||
|
content: editorContent,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await writeContainerFile.mutateAsync({
|
||||||
|
containerId: containerId ?? "",
|
||||||
|
path: selectedFile,
|
||||||
|
content: editorContent,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success("File saved");
|
||||||
|
refetchFile();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to save file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEntry = async (entryPath: string) => {
|
||||||
|
try {
|
||||||
|
if (isVolume) {
|
||||||
|
await deleteVolumeFile.mutateAsync({
|
||||||
|
volumeName,
|
||||||
|
path: entryPath,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
await utils.dockerVolume.listVolumeFiles.invalidate();
|
||||||
|
} else {
|
||||||
|
await deleteContainerFile.mutateAsync({
|
||||||
|
containerId: containerId ?? "",
|
||||||
|
path: entryPath,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
await utils.docker.listContainerFiles.invalidate();
|
||||||
|
}
|
||||||
|
toast.success("Deleted");
|
||||||
|
if (selectedFile === entryPath) {
|
||||||
|
setSelectedFile(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to delete");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = path.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
const navigate = (nextPath: string) => {
|
||||||
|
setPath(nextPath);
|
||||||
|
setSelectedFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (value: boolean) => {
|
||||||
|
setOpen(value);
|
||||||
|
if (!value) {
|
||||||
|
setPath("/");
|
||||||
|
setSelectedFile(null);
|
||||||
|
setEditorContent("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFile = () => {
|
||||||
|
if (!fileBytes || !selectedFile) return;
|
||||||
|
const blob = new Blob([fileBytes]);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = selectedFile.split("/").pop() ?? "file";
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{asDropdownItem ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer space-x-3"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-6xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FolderOpen className="size-5" />
|
||||||
|
{isVolume ? "Volume Files" : "Container Files"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isVolume
|
||||||
|
? `Browse and edit files inside the "${volumeName}" volume`
|
||||||
|
: "Browse and edit files inside the container's filesystem"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 text-sm font-mono flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:underline text-muted-foreground"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</button>
|
||||||
|
{breadcrumbs.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={`/${breadcrumbs.slice(0, index + 1).join("/")}`}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:underline text-muted-foreground"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/${breadcrumbs.slice(0, index + 1).join("/")}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{segment}
|
||||||
|
</button>
|
||||||
|
{index < breadcrumbs.length - 1 && (
|
||||||
|
<ChevronRight className="size-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="ml-auto size-7"
|
||||||
|
onClick={() => refetchEntries()}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`size-4 ${isRefetching ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 min-h-[55vh] max-h-[65vh]">
|
||||||
|
<div className="w-72 shrink-0 border rounded-lg overflow-y-auto">
|
||||||
|
{isLoadingEntries ? (
|
||||||
|
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground p-4">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : entriesError ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<AlertBlock type="error">{entriesError.message}</AlertBlock>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col p-1">
|
||||||
|
{path !== "/" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted text-left"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/${breadcrumbs.slice(0, -1).join("/")}` || "/")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Folder className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
..
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{entries?.length === 0 && (
|
||||||
|
<span className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||||
|
Empty directory
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entries?.map((entry) => {
|
||||||
|
const entryPath = joinPath(path, entry.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.name}
|
||||||
|
className={`group flex items-center rounded-md hover:bg-muted ${
|
||||||
|
selectedFile === entryPath ? "bg-muted" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 items-center gap-2 px-2 py-1.5 text-sm text-left min-w-0"
|
||||||
|
onClick={() =>
|
||||||
|
entry.isDirectory
|
||||||
|
? navigate(entryPath)
|
||||||
|
: setSelectedFile(entryPath)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{entry.isDirectory ? (
|
||||||
|
<Folder className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<File className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
<DialogAction
|
||||||
|
title={`Delete ${entry.name}?`}
|
||||||
|
description={`This will permanently delete ${entryPath}${entry.isDirectory ? " and all its contents" : ""}.`}
|
||||||
|
onClick={() => deleteEntry(entryPath)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 opacity-0 group-hover:opacity-100 shrink-0"
|
||||||
|
isLoading={isDeleting}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
||||||
|
{!selectedFile ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
||||||
|
Select a file to view or edit it
|
||||||
|
</div>
|
||||||
|
) : isLoadingFile ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground rounded-lg border">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : fileError ? (
|
||||||
|
<AlertBlock type="error">{fileError.message}</AlertBlock>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-mono truncate text-muted-foreground">
|
||||||
|
{selectedFile}
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={downloadFile}>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
isLoading={isSaving}
|
||||||
|
disabled={isBinary || file?.truncated}
|
||||||
|
onClick={saveFile}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{file?.truncated ? (
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
This file is larger than 512KB. Showing a truncated preview;
|
||||||
|
editing is disabled. Use Download to get the truncated
|
||||||
|
content or the terminal for full access.
|
||||||
|
</AlertBlock>
|
||||||
|
) : isBinary ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
||||||
|
Binary file — use Download instead
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-auto rounded-lg border">
|
||||||
|
<CodeEditor
|
||||||
|
lineWrapping
|
||||||
|
value={editorContent}
|
||||||
|
onChange={(value) => setEditorContent(value)}
|
||||||
|
wrapperClassName="h-full font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ShowContainerConfig } from "../config/show-container-config";
|
import { ShowContainerConfig } from "../config/show-container-config";
|
||||||
|
import { FilesExplorerModal } from "../files/files-explorer-modal";
|
||||||
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||||
import { ShowContainerMounts } from "../mounts/show-container-mounts";
|
import { ShowContainerMounts } from "../mounts/show-container-mounts";
|
||||||
import { ShowContainerNetworks } from "../networks/show-container-networks";
|
import { ShowContainerNetworks } from "../networks/show-container-networks";
|
||||||
@@ -173,6 +174,12 @@ export const columns: ColumnDef<Container>[] = [
|
|||||||
>
|
>
|
||||||
Terminal
|
Terminal
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
|
<FilesExplorerModal
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={container.serverId || undefined}
|
||||||
|
>
|
||||||
|
Browse Files
|
||||||
|
</FilesExplorerModal>
|
||||||
<UploadFileModal
|
<UploadFileModal
|
||||||
containerId={container.containerId}
|
containerId={container.containerId}
|
||||||
serverId={container.serverId || undefined}
|
serverId={container.serverId || undefined}
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type PaginationState,
|
||||||
|
type SortingState,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
Eye,
|
||||||
|
FolderOpen,
|
||||||
|
HardDrive,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { FilesExplorerModal } from "@/components/dashboard/docker/files/files-explorer-modal";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type { AppRouter } from "@/server/api/root";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
type VolumeRow =
|
||||||
|
inferRouterOutputs<AppRouter>["dockerVolume"]["getVolumes"][number];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_UNITS: Record<string, number> = {
|
||||||
|
b: 1,
|
||||||
|
kb: 1e3,
|
||||||
|
mb: 1e6,
|
||||||
|
gb: 1e9,
|
||||||
|
tb: 1e12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSize = (size?: string) => {
|
||||||
|
if (!size) return -1;
|
||||||
|
const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim());
|
||||||
|
if (!match?.[1] || !match[2]) return -1;
|
||||||
|
return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortableHeader = ({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
column: {
|
||||||
|
getIsSorted: () => false | "asc" | "desc";
|
||||||
|
toggleSorting: (asc: boolean) => void;
|
||||||
|
};
|
||||||
|
title: string;
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
<ArrowUpDown className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ShowVolumeConfig = ({
|
||||||
|
volumeName,
|
||||||
|
serverId,
|
||||||
|
}: {
|
||||||
|
volumeName: string;
|
||||||
|
serverId?: string;
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { data, isLoading, error } = api.dockerVolume.getVolumeConfig.useQuery(
|
||||||
|
{ volumeName, serverId },
|
||||||
|
{ enabled: open },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="View volume config">
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Volume Config</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
docker volume inspect output for "{volumeName}"
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{error ? (
|
||||||
|
<AlertBlock type="error">{error.message}</AlertBlock>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
|
||||||
|
<code>
|
||||||
|
<pre className="whitespace-pre-wrap wrap-break-word">
|
||||||
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
lineWrapping
|
||||||
|
lineNumbers={false}
|
||||||
|
readOnly
|
||||||
|
value={JSON.stringify(data, null, 2)}
|
||||||
|
/>
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowVolumes = ({ serverId }: Props) => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: "Name", desc: false },
|
||||||
|
]);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("");
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: volumes, isLoading } = api.dockerVolume.getVolumes.useQuery({
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
const { data: sizes, isLoading: isLoadingSizes } =
|
||||||
|
api.dockerVolume.getVolumesSize.useQuery({ serverId });
|
||||||
|
|
||||||
|
const sizeByName = useMemo(
|
||||||
|
() => new Map((sizes ?? []).map((entry) => [entry.name, entry.size])),
|
||||||
|
[sizes],
|
||||||
|
);
|
||||||
|
const { mutateAsync: removeVolume } =
|
||||||
|
api.dockerVolume.removeVolume.useMutation();
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
const list = volumes ?? [];
|
||||||
|
if (!globalFilter.trim()) {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
const query = globalFilter.toLowerCase();
|
||||||
|
return list.filter((volume) => volume.Name.toLowerCase().includes(query));
|
||||||
|
}, [volumes, globalFilter]);
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<VolumeRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "Name",
|
||||||
|
header: ({ column }) => <SortableHeader column={column} title="Name" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div
|
||||||
|
className="max-w-[280px] truncate font-medium"
|
||||||
|
title={row.original.Name}
|
||||||
|
>
|
||||||
|
{row.original.Name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "Driver",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader column={column} title="Driver" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="outline">{row.original.Driver}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "Scope",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader column={column} title="Scope" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground">{row.original.Scope}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Size",
|
||||||
|
header: ({ column }) => <SortableHeader column={column} title="Size" />,
|
||||||
|
sortingFn: (a, b) =>
|
||||||
|
parseSize(sizeByName.get(a.original.Name) ?? undefined) -
|
||||||
|
parseSize(sizeByName.get(b.original.Name) ?? undefined),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
isLoadingSizes ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{sizeByName.get(row.original.Name) ?? "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "Mountpoint",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader column={column} title="Mountpoint" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div
|
||||||
|
className="max-w-[320px] truncate font-mono text-xs text-muted-foreground"
|
||||||
|
title={row.original.Mountpoint}
|
||||||
|
>
|
||||||
|
{row.original.Mountpoint}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => <div className="text-right">Actions</div>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<FilesExplorerModal
|
||||||
|
volumeName={row.original.Name}
|
||||||
|
serverId={serverId}
|
||||||
|
asDropdownItem={false}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Browse volume files"
|
||||||
|
>
|
||||||
|
<FolderOpen className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</FilesExplorerModal>
|
||||||
|
<ShowVolumeConfig
|
||||||
|
volumeName={row.original.Name}
|
||||||
|
serverId={serverId}
|
||||||
|
/>
|
||||||
|
<DialogAction
|
||||||
|
title="Delete volume"
|
||||||
|
description={`The volume "${row.original.Name}" will be removed from Docker. This action cannot be undone.`}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await removeVolume({
|
||||||
|
volumeName: row.original.Name,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
toast.success("Volume deleted");
|
||||||
|
await utils.dockerVolume.getVolumes.invalidate();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Error deleting volume", {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="Delete volume">
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[serverId, removeVolume, utils, sizeByName, isLoadingSizes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
pagination,
|
||||||
|
},
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||||
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
|
<CardHeader className="">
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<HardDrive className="size-6 text-muted-foreground self-center" />
|
||||||
|
Volumes
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage the Docker volumes of the selected server.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4 py-8 border-t">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : !volumes?.length ? (
|
||||||
|
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
|
||||||
|
<div className="rounded-full bg-muted p-4">
|
||||||
|
<HardDrive className="size-10 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<p className="text-sm font-medium">No volumes found</p>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
|
Docker volumes created on this server will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No volumes match your filters.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{table.getPageCount() > 1 && (
|
||||||
|
<div className="flex items-center justify-end gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||||
|
{table.getPageCount()}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/router";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
|
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
|
||||||
|
import { ShowVolumes } from "@/components/dashboard/docker/volumes/show-volumes";
|
||||||
import { ShowNetworks } from "@/components/dashboard/networks/show-networks";
|
import { ShowNetworks } from "@/components/dashboard/networks/show-networks";
|
||||||
import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers";
|
import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers";
|
||||||
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
|
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
|
||||||
@@ -44,6 +45,7 @@ const Dashboard = () => {
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||||
<TabsTrigger value="swarm">Swarm</TabsTrigger>
|
<TabsTrigger value="swarm">Swarm</TabsTrigger>
|
||||||
|
<TabsTrigger value="volumes">Volumes</TabsTrigger>
|
||||||
{!isCloud && <TabsTrigger value="networks">Networks</TabsTrigger>}
|
{!isCloud && <TabsTrigger value="networks">Networks</TabsTrigger>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="containers">
|
<TabsContent value="containers">
|
||||||
@@ -67,6 +69,9 @@ const Dashboard = () => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="volumes">
|
||||||
|
<ShowVolumes serverId={serverId} />
|
||||||
|
</TabsContent>
|
||||||
{!isCloud && (
|
{!isCloud && (
|
||||||
<TabsContent value="networks">
|
<TabsContent value="networks">
|
||||||
<ShowNetworks serverId={serverId} />
|
<ShowNetworks serverId={serverId} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { composeRouter } from "./routers/compose";
|
|||||||
import { deploymentRouter } from "./routers/deployment";
|
import { deploymentRouter } from "./routers/deployment";
|
||||||
import { destinationRouter } from "./routers/destination";
|
import { destinationRouter } from "./routers/destination";
|
||||||
import { dockerRouter } from "./routers/docker";
|
import { dockerRouter } from "./routers/docker";
|
||||||
|
import { dockerVolumeRouter } from "./routers/docker-volume";
|
||||||
import { domainRouter } from "./routers/domain";
|
import { domainRouter } from "./routers/domain";
|
||||||
import { environmentRouter } from "./routers/environment";
|
import { environmentRouter } from "./routers/environment";
|
||||||
import { gitProviderRouter } from "./routers/git-provider";
|
import { gitProviderRouter } from "./routers/git-provider";
|
||||||
@@ -68,6 +69,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
deployment: deploymentRouter,
|
deployment: deploymentRouter,
|
||||||
destination: destinationRouter,
|
destination: destinationRouter,
|
||||||
docker: dockerRouter,
|
docker: dockerRouter,
|
||||||
|
dockerVolume: dockerVolumeRouter,
|
||||||
domain: domainRouter,
|
domain: domainRouter,
|
||||||
gitea: giteaRouter,
|
gitea: giteaRouter,
|
||||||
gitProvider: gitProviderRouter,
|
gitProvider: gitProviderRouter,
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import {
|
||||||
|
deleteVolumeFile,
|
||||||
|
findServerById,
|
||||||
|
getVolumeConfig,
|
||||||
|
getVolumes,
|
||||||
|
getVolumesSize,
|
||||||
|
listVolumeFiles,
|
||||||
|
readVolumeFile,
|
||||||
|
removeVolume,
|
||||||
|
writeVolumeFile,
|
||||||
|
} from "@dokploy/server";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import { createTRPCRouter, withPermission } from "../trpc";
|
||||||
|
|
||||||
|
export const volumeNameRegex = /^[a-zA-Z0-9.\-_]+$/;
|
||||||
|
|
||||||
|
const volumePathSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(4096)
|
||||||
|
.refine(
|
||||||
|
(path) => path.startsWith("/") && !path.includes("\0"),
|
||||||
|
"Path must be absolute.",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dockerVolumeRouter = createTRPCRouter({
|
||||||
|
getVolumes: withPermission("docker", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await getVolumes(input.serverId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getVolumesSize: withPermission("docker", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await getVolumesSize(input.serverId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
listVolumeFiles: withPermission("docker", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
volumeName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(volumeNameRegex, "Invalid volume name."),
|
||||||
|
path: volumePathSchema,
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await listVolumeFiles(
|
||||||
|
input.volumeName,
|
||||||
|
input.path,
|
||||||
|
input.serverId,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
readVolumeFile: withPermission("docker", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
volumeName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(volumeNameRegex, "Invalid volume name."),
|
||||||
|
path: volumePathSchema,
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await readVolumeFile(input.volumeName, input.path, input.serverId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
writeVolumeFile: withPermission("docker", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
volumeName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(volumeNameRegex, "Invalid volume name."),
|
||||||
|
path: volumePathSchema,
|
||||||
|
content: z.string(),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeVolumeFile(
|
||||||
|
input.volumeName,
|
||||||
|
input.path,
|
||||||
|
input.content,
|
||||||
|
input.serverId,
|
||||||
|
);
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "update",
|
||||||
|
resourceType: "docker",
|
||||||
|
resourceId: input.volumeName,
|
||||||
|
resourceName: `${input.volumeName}:${input.path}`,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteVolumeFile: withPermission("docker", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
volumeName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(volumeNameRegex, "Invalid volume name."),
|
||||||
|
path: volumePathSchema.refine(
|
||||||
|
(path) => path !== "/",
|
||||||
|
"Cannot delete the volume root.",
|
||||||
|
),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await deleteVolumeFile(input.volumeName, input.path, input.serverId);
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "delete",
|
||||||
|
resourceType: "docker",
|
||||||
|
resourceId: input.volumeName,
|
||||||
|
resourceName: `${input.volumeName}:${input.path}`,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
getVolumeConfig: withPermission("docker", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
volumeName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(volumeNameRegex, "Invalid volume name."),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await getVolumeConfig(input.volumeName, input.serverId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeVolume: withPermission("docker", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
volumeName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(volumeNameRegex, "Invalid volume name."),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await removeVolume(input.volumeName, input.serverId);
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "delete",
|
||||||
|
resourceType: "docker",
|
||||||
|
resourceId: input.volumeName,
|
||||||
|
resourceName: input.volumeName,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
containerRestart,
|
containerRestart,
|
||||||
containerStart,
|
containerStart,
|
||||||
containerStop,
|
containerStop,
|
||||||
|
deleteContainerFile,
|
||||||
findServerById,
|
findServerById,
|
||||||
getConfig,
|
getConfig,
|
||||||
getContainers,
|
getContainers,
|
||||||
@@ -11,7 +12,10 @@ import {
|
|||||||
getContainersByAppNameMatch,
|
getContainersByAppNameMatch,
|
||||||
getServiceContainersByAppName,
|
getServiceContainersByAppName,
|
||||||
getStackContainersByAppName,
|
getStackContainersByAppName,
|
||||||
|
listContainerFiles,
|
||||||
|
readContainerFile,
|
||||||
uploadFileToContainer,
|
uploadFileToContainer,
|
||||||
|
writeContainerFile,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -21,6 +25,15 @@ import { createTRPCRouter, withPermission } from "../trpc";
|
|||||||
|
|
||||||
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
|
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
|
||||||
|
|
||||||
|
const containerPathSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(4096)
|
||||||
|
.refine(
|
||||||
|
(path) => path.startsWith("/") && !path.includes("\0"),
|
||||||
|
"Path must be absolute.",
|
||||||
|
);
|
||||||
|
|
||||||
export const dockerRouter = createTRPCRouter({
|
export const dockerRouter = createTRPCRouter({
|
||||||
getContainers: withPermission("docker", "read")
|
getContainers: withPermission("docker", "read")
|
||||||
.input(
|
.input(
|
||||||
@@ -298,4 +311,117 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true, message: "File uploaded successfully" };
|
return { success: true, message: "File uploaded successfully" };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listContainerFiles: withPermission("docker", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
containerId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(containerIdRegex, "Invalid container id."),
|
||||||
|
path: containerPathSchema,
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await listContainerFiles(
|
||||||
|
input.containerId,
|
||||||
|
input.path,
|
||||||
|
input.serverId,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
readContainerFile: withPermission("docker", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
containerId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(containerIdRegex, "Invalid container id."),
|
||||||
|
path: containerPathSchema,
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await readContainerFile(
|
||||||
|
input.containerId,
|
||||||
|
input.path,
|
||||||
|
input.serverId,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
writeContainerFile: withPermission("docker", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
containerId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(containerIdRegex, "Invalid container id."),
|
||||||
|
path: containerPathSchema,
|
||||||
|
content: z.string(),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeContainerFile(
|
||||||
|
input.containerId,
|
||||||
|
input.path,
|
||||||
|
input.content,
|
||||||
|
input.serverId,
|
||||||
|
);
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "update",
|
||||||
|
resourceType: "docker",
|
||||||
|
resourceId: input.containerId,
|
||||||
|
resourceName: `${input.containerId}:${input.path}`,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteContainerFile: withPermission("docker", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
containerId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(containerIdRegex, "Invalid container id."),
|
||||||
|
path: containerPathSchema.refine(
|
||||||
|
(path) => path !== "/",
|
||||||
|
"Cannot delete the container root.",
|
||||||
|
),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await deleteContainerFile(input.containerId, input.path, input.serverId);
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "delete",
|
||||||
|
resourceType: "docker",
|
||||||
|
resourceId: input.containerId,
|
||||||
|
resourceName: `${input.containerId}:${input.path}`,
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export * from "./services/compose";
|
|||||||
export * from "./services/deployment";
|
export * from "./services/deployment";
|
||||||
export * from "./services/destination";
|
export * from "./services/destination";
|
||||||
export * from "./services/docker";
|
export * from "./services/docker";
|
||||||
|
export * from "./services/docker-volume";
|
||||||
export * from "./services/domain";
|
export * from "./services/domain";
|
||||||
export * from "./services/environment";
|
export * from "./services/environment";
|
||||||
export * from "./services/git-provider";
|
export * from "./services/git-provider";
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
execAsync,
|
||||||
|
execAsyncRemote,
|
||||||
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import { quote } from "shell-quote";
|
||||||
|
import { CONTAINER_FILE_SIZE_LIMIT } from "./docker";
|
||||||
|
|
||||||
|
export interface DockerVolume {
|
||||||
|
Name: string;
|
||||||
|
Driver: string;
|
||||||
|
Scope: string;
|
||||||
|
Mountpoint: string;
|
||||||
|
Labels: string;
|
||||||
|
Availability?: string;
|
||||||
|
Group?: string;
|
||||||
|
Links?: string;
|
||||||
|
Size?: string;
|
||||||
|
Status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VOLUME_MOUNT = "/__volume";
|
||||||
|
|
||||||
|
export const getVolumes = async (serverId?: string) => {
|
||||||
|
try {
|
||||||
|
const command = "docker volume ls --format '{{json .}}'";
|
||||||
|
const { stdout } = serverId
|
||||||
|
? await execAsyncRemote(serverId, command)
|
||||||
|
: await execAsync(command);
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line) as DockerVolume);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVolumesSize = async (serverId?: string) => {
|
||||||
|
try {
|
||||||
|
const command = "docker system df -v --format '{{json .}}'";
|
||||||
|
const { stdout } = serverId
|
||||||
|
? await execAsyncRemote(serverId, command)
|
||||||
|
: await execAsync(command);
|
||||||
|
|
||||||
|
const diskUsage = JSON.parse(stdout.trim());
|
||||||
|
return ((diskUsage?.Volumes ?? []) as Partial<DockerVolume>[])
|
||||||
|
.filter((volume) => volume?.Name)
|
||||||
|
.map((volume) => ({
|
||||||
|
name: volume.Name as string,
|
||||||
|
size: volume.Size ?? null,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVolumeConfig = async (
|
||||||
|
volumeName: string,
|
||||||
|
serverId?: string,
|
||||||
|
) => {
|
||||||
|
const command = `docker volume inspect ${quote([String(volumeName ?? "")])}`;
|
||||||
|
const { stdout } = serverId
|
||||||
|
? await execAsyncRemote(serverId, command)
|
||||||
|
: await execAsync(command);
|
||||||
|
|
||||||
|
return JSON.parse(stdout.trim())[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeVolume = async (volumeName: string, serverId?: string) => {
|
||||||
|
const command = `docker volume rm ${quote([String(volumeName ?? "")])}`;
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listVolumeFiles = async (
|
||||||
|
volumeName: string,
|
||||||
|
path: string,
|
||||||
|
serverId?: string,
|
||||||
|
) => {
|
||||||
|
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}:ro`])} busybox ls -1Ap ${quote([`${VOLUME_MOUNT}${path}`])}`;
|
||||||
|
const { stdout } = serverId
|
||||||
|
? await execAsyncRemote(serverId, command)
|
||||||
|
: await execAsync(command);
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => ({
|
||||||
|
name: entry.endsWith("/") ? entry.slice(0, -1) : entry,
|
||||||
|
isDirectory: entry.endsWith("/"),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isDirectory !== b.isDirectory) {
|
||||||
|
return a.isDirectory ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readVolumeFile = async (
|
||||||
|
volumeName: string,
|
||||||
|
filePath: string,
|
||||||
|
serverId?: string,
|
||||||
|
) => {
|
||||||
|
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}:ro`])} busybox cat ${quote([`${VOLUME_MOUNT}${filePath}`])} | head -c ${CONTAINER_FILE_SIZE_LIMIT + 1} | base64 | tr -d '\\n'`;
|
||||||
|
const { stdout, stderr } = serverId
|
||||||
|
? await execAsyncRemote(serverId, command)
|
||||||
|
: await execAsync(command);
|
||||||
|
|
||||||
|
if (stderr && !stdout) {
|
||||||
|
throw new Error(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(stdout.trim(), "base64");
|
||||||
|
return {
|
||||||
|
content: buffer.subarray(0, CONTAINER_FILE_SIZE_LIMIT).toString("base64"),
|
||||||
|
truncated: buffer.byteLength > CONTAINER_FILE_SIZE_LIMIT,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeVolumeFile = async (
|
||||||
|
volumeName: string,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
serverId?: string,
|
||||||
|
) => {
|
||||||
|
const base64Content = Buffer.from(content, "utf8").toString("base64");
|
||||||
|
if (base64Content.length > CONTAINER_FILE_SIZE_LIMIT * 2) {
|
||||||
|
throw new Error("File is too large to save from the editor (max 512KB)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const innerCommand = `printf '%s' ${base64Content} | base64 -d > ${quote([`${VOLUME_MOUNT}${filePath}`])}`;
|
||||||
|
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}`])} busybox sh -c ${quote([innerCommand])}`;
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteVolumeFile = async (
|
||||||
|
volumeName: string,
|
||||||
|
path: string,
|
||||||
|
serverId?: string,
|
||||||
|
) => {
|
||||||
|
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}`])} busybox rm -rf ${quote([`${VOLUME_MOUNT}${path}`])}`;
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ExecError,
|
||||||
execAsync,
|
execAsync,
|
||||||
execAsyncRemote,
|
execAsyncRemote,
|
||||||
} from "@dokploy/server/utils/process/execAsync";
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
@@ -760,3 +761,113 @@ export const uploadFileToContainer = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CONTAINER_FILE_SIZE_LIMIT = 512 * 1024;
|
||||||
|
|
||||||
|
const NO_SHELL_UTILITIES_ERROR =
|
||||||
|
"This container image has no shell utilities and its filesystem is not accessible from the Dokploy host.";
|
||||||
|
|
||||||
|
const isMissingBinaryError = (error: unknown) =>
|
||||||
|
error instanceof ExecError &&
|
||||||
|
(error.exitCode === 126 ||
|
||||||
|
error.exitCode === 127 ||
|
||||||
|
!!error.stderr?.includes("executable file not found"));
|
||||||
|
|
||||||
|
export const listContainerFiles = async (
|
||||||
|
containerId: string,
|
||||||
|
path: string,
|
||||||
|
serverId?: string,
|
||||||
|
) => {
|
||||||
|
const command = `docker exec ${quote([containerId])} ls -1Ap ${quote([path])}`;
|
||||||
|
let stdout: string;
|
||||||
|
try {
|
||||||
|
({ stdout } = serverId
|
||||||
|
? await execAsyncRemote(serverId, command)
|
||||||
|
: await execAsync(command));
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingBinaryError(error)) {
|
||||||
|
throw new Error(NO_SHELL_UTILITIES_ERROR);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => ({
|
||||||
|
name: entry.endsWith("/") ? entry.slice(0, -1) : entry,
|
||||||
|
isDirectory: entry.endsWith("/"),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isDirectory !== b.isDirectory) {
|
||||||
|
return a.isDirectory ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readContainerFile = async (
|
||||||
|
containerId: string,
|
||||||
|
filePath: string,
|
||||||
|
serverId?: string,
|
||||||
|
) => {
|
||||||
|
const command = `docker exec ${quote([containerId])} cat ${quote([filePath])} | head -c ${CONTAINER_FILE_SIZE_LIMIT + 1} | base64 | tr -d '\\n'`;
|
||||||
|
const { stdout, stderr } = serverId
|
||||||
|
? await execAsyncRemote(serverId, command)
|
||||||
|
: await execAsync(command);
|
||||||
|
|
||||||
|
if (stderr && !stdout) {
|
||||||
|
if (stderr.includes("executable file not found")) {
|
||||||
|
throw new Error(NO_SHELL_UTILITIES_ERROR);
|
||||||
|
}
|
||||||
|
throw new Error(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(stdout.trim(), "base64");
|
||||||
|
return {
|
||||||
|
content: buffer.subarray(0, CONTAINER_FILE_SIZE_LIMIT).toString("base64"),
|
||||||
|
truncated: buffer.byteLength > CONTAINER_FILE_SIZE_LIMIT,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeContainerFile = async (
|
||||||
|
containerId: string,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
serverId?: string,
|
||||||
|
) => {
|
||||||
|
const base64Content = Buffer.from(content, "utf8").toString("base64");
|
||||||
|
if (base64Content.length > CONTAINER_FILE_SIZE_LIMIT * 2) {
|
||||||
|
throw new Error("File is too large to save from the editor (max 512KB)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPath = `/tmp/dokploy-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
const command = `printf '%s' ${quote([base64Content])} | base64 -d > ${quote([tempPath])} && docker cp ${quote([tempPath])} ${quote([`${containerId}:${filePath}`])}; status=$?; rm -f ${quote([tempPath])}; exit $status`;
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteContainerFile = async (
|
||||||
|
containerId: string,
|
||||||
|
path: string,
|
||||||
|
serverId?: string,
|
||||||
|
) => {
|
||||||
|
const command = `docker exec ${quote([containerId])} rm -rf ${quote([path])}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingBinaryError(error)) {
|
||||||
|
throw new Error(NO_SHELL_UTILITIES_ERROR);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user