Compare commits

..

3 Commits

9 changed files with 86 additions and 17 deletions

View File

@ -55,7 +55,7 @@ services:
Get the latest binary from the [release page](https://github.com/khairul169/garage-webui/releases/latest) according to your OS architecture. For example: Get the latest binary from the [release page](https://github.com/khairul169/garage-webui/releases/latest) according to your OS architecture. For example:
```sh ```sh
$ wget -O garage-webui https://github.com/khairul169/garage-webui/releases/download/1.0.2/garage-webui-v1.0.2-linux-amd64 $ wget -O garage-webui https://github.com/khairul169/garage-webui/releases/download/1.0.3/garage-webui-v1.0.3-linux-amd64
$ chmod +x garage-webui $ chmod +x garage-webui
$ sudo cp garage-webui /usr/local/bin $ sudo cp garage-webui /usr/local/bin
``` ```

View File

@ -16,6 +16,7 @@ import (
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go" "github.com/aws/smithy-go"
) )
@ -183,6 +184,8 @@ func (b *Browse) PutObject(w http.ResponseWriter, r *http.Request) {
func (b *Browse) DeleteObject(w http.ResponseWriter, r *http.Request) { func (b *Browse) DeleteObject(w http.ResponseWriter, r *http.Request) {
bucket := r.PathValue("bucket") bucket := r.PathValue("bucket")
key := r.PathValue("key") key := r.PathValue("key")
recursive := r.URL.Query().Get("recursive") == "true"
isDirectory := strings.HasSuffix(key, "/")
client, err := getS3Client(bucket) client, err := getS3Client(bucket)
if err != nil { if err != nil {
@ -190,7 +193,52 @@ func (b *Browse) DeleteObject(w http.ResponseWriter, r *http.Request) {
return return
} }
_, err = client.DeleteObject(context.Background(), &s3.DeleteObjectInput{ // Delete directory and its content
if isDirectory && recursive {
objects, err := client.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(key),
})
if err != nil {
utils.ResponseError(w, err)
return
}
if len(objects.Contents) == 0 {
utils.ResponseSuccess(w, true)
return
}
keys := make([]types.ObjectIdentifier, 0, len(objects.Contents))
for _, object := range objects.Contents {
keys = append(keys, types.ObjectIdentifier{
Key: object.Key,
})
}
res, err := client.DeleteObjects(context.Background(), &s3.DeleteObjectsInput{
Bucket: aws.String(bucket),
Delete: &types.Delete{Objects: keys},
})
if err != nil {
utils.ResponseError(w, fmt.Errorf("cannot delete object: %w", err))
return
}
if len(res.Errors) > 0 {
utils.ResponseError(w, fmt.Errorf("cannot delete object: %v", res.Errors[0]))
return
}
utils.ResponseSuccess(w, res)
return
}
// Delete single object
res, err := client.DeleteObject(context.Background(), &s3.DeleteObjectInput{
Bucket: aws.String(bucket), Bucket: aws.String(bucket),
Key: aws.String(key), Key: aws.String(key),
}) })
@ -200,7 +248,7 @@ func (b *Browse) DeleteObject(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.ResponseSuccess(w, nil) utils.ResponseSuccess(w, res)
} }
func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) { func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) {

View File

@ -1,7 +1,7 @@
{ {
"name": "garage-webui", "name": "garage-webui",
"private": true, "private": true,
"version": "1.0.2", "version": "1.0.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev:client": "vite", "dev:client": "vite",

View File

@ -21,7 +21,7 @@ const MainLayout = () => {
<Drawer <Drawer
open={sidebar.isOpen} open={sidebar.isOpen}
onClickOverlay={sidebar.onClose} onClickOverlay={sidebar.onClose}
className="md:drawer-open h-screen" className="md:drawer-open h-screen max-h-dvh"
side={<Sidebar />} side={<Sidebar />}
contentClassName="flex flex-col overflow-hidden" contentClassName="flex flex-col overflow-hidden"
> >

View File

@ -40,10 +40,13 @@ export const usePutObject = (
export const useDeleteObject = ( export const useDeleteObject = (
bucket: string, bucket: string,
options?: UseMutationOptions<any, Error, string> options?: UseMutationOptions<any, Error, { key: string; recursive?: boolean }>
) => { ) => {
return useMutation({ return useMutation({
mutationFn: (key) => api.delete(`/browse/${bucket}/${key}`), mutationFn: (data) =>
api.delete(`/browse/${bucket}/${data.key}`, {
params: { recursive: data.recursive },
}),
...options, ...options,
}); });
}; };

View File

@ -13,9 +13,10 @@ import { shareDialog } from "./share-dialog";
type Props = { type Props = {
prefix?: string; prefix?: string;
object: Pick<Object, "objectKey" | "downloadUrl">; object: Pick<Object, "objectKey" | "downloadUrl">;
end?: boolean;
}; };
const ObjectActions = ({ prefix = "", object }: Props) => { const ObjectActions = ({ prefix = "", object, end }: Props) => {
const { bucketName } = useBucketContext(); const { bucketName } = useBucketContext();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDirectory = object.objectKey.endsWith("/"); const isDirectory = object.objectKey.endsWith("/");
@ -33,8 +34,17 @@ const ObjectActions = ({ prefix = "", object }: Props) => {
}; };
const onDelete = () => { const onDelete = () => {
if (window.confirm("Are you sure you want to delete this object?")) { if (
deleteObject.mutate(prefix + object.objectKey); window.confirm(
`Are you sure you want to delete this ${
isDirectory ? "directory and its content" : "object"
}?`
)
) {
deleteObject.mutate({
key: prefix + object.objectKey,
recursive: isDirectory,
});
} }
}; };
@ -45,12 +55,12 @@ const ObjectActions = ({ prefix = "", object }: Props) => {
<Button icon={DownloadIcon} color="ghost" onClick={onDownload} /> <Button icon={DownloadIcon} color="ghost" onClick={onDownload} />
)} )}
<Dropdown end> <Dropdown end vertical={end ? "top" : "bottom"}>
<Dropdown.Toggle button={false}> <Dropdown.Toggle button={false}>
<Button icon={EllipsisVertical} color="ghost" /> <Button icon={EllipsisVertical} color="ghost" />
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu className="bg-base-300 gap-y-1"> <Dropdown.Menu className="gap-y-1">
<Dropdown.Item <Dropdown.Item
onClick={() => onClick={() =>
shareDialog.open({ key: object.objectKey, prefix }) shareDialog.open({ key: object.objectKey, prefix })

View File

@ -23,7 +23,7 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
}; };
return ( return (
<div className="overflow-x-auto pb-32"> <div className="overflow-x-auto overflow-y-hidden">
<Table> <Table>
<Table.Head> <Table.Head>
<span>Name</span> <span>Name</span>
@ -63,7 +63,7 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
</tr> </tr>
))} ))}
{data?.objects.map((object) => { {data?.objects.map((object, idx) => {
const extIdx = object.objectKey.lastIndexOf("."); const extIdx = object.objectKey.lastIndexOf(".");
const filename = const filename =
extIdx >= 0 extIdx >= 0
@ -93,7 +93,11 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
<td className="whitespace-nowrap"> <td className="whitespace-nowrap">
{dayjs(object.lastModified).fromNow()} {dayjs(object.lastModified).fromNow()}
</td> </td>
<ObjectActions prefix={data.prefix} object={object} /> <ObjectActions
prefix={data.prefix}
object={object}
end={idx >= data.objects.length - 2}
/>
</tr> </tr>
); );
})} })}

View File

@ -184,7 +184,7 @@ const NodesList = ({ nodes }: NodeListProps) => {
</Alert> </Alert>
) : null} ) : null}
<div className="w-full overflow-x-auto min-h-[400px] pb-16"> <div className="w-full overflow-x-auto overflow-y-hidden min-h-[400px]">
<Table size="sm" className="min-w-[800px]"> <Table size="sm" className="min-w-[800px]">
<Table.Head> <Table.Head>
<span>#</span> <span>#</span>
@ -266,7 +266,10 @@ const NodesList = ({ nodes }: NodeListProps) => {
: "Inactive"} : "Inactive"}
</Badge> </Badge>
<Dropdown end> <Dropdown
end
vertical={idx >= items.length - 2 ? "top" : "bottom"}
>
<Dropdown.Toggle button={false}> <Dropdown.Toggle button={false}>
<Button shape="circle" color="ghost"> <Button shape="circle" color="ghost">
<EllipsisVertical /> <EllipsisVertical />

View File

@ -96,6 +96,7 @@ const KeysPage = () => {
icon={Eye} icon={Eye}
size="sm" size="sm"
onClick={() => fetchSecretKey(key.id)} onClick={() => fetchSecretKey(key.id)}
className="shrink-0 min-w-[80px]"
> >
View View
</Button> </Button>