6 Commits
17 changed files with 157 additions and 63 deletions
+2 -2
View File
@@ -31,7 +31,7 @@ If you install Garage using Docker, you can install this web UI alongside Garage
```yml ```yml
services: services:
garage: garage:
image: dxflrs/garage:v1.0.0 image: dxflrs/garage:v1.0.1
container_name: garage container_name: garage
volumes: volumes:
- ./garage.toml:/etc/garage.toml - ./garage.toml:/etc/garage.toml
@@ -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.1/garage-webui-v1.0.1-linux-amd64 $ wget -O garage-webui https://github.com/khairul169/garage-webui/releases/download/1.0.4/garage-webui-v1.0.4-linux-amd64
$ chmod +x garage-webui $ chmod +x garage-webui
$ sudo cp garage-webui /usr/local/bin $ sudo cp garage-webui /usr/local/bin
``` ```
+50 -2
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) {
+4 -3
View File
@@ -34,13 +34,14 @@ func (b *Buckets) GetAll(w http.ResponseWriter, r *http.Request) {
return return
} }
var bucket schema.Bucket var data schema.Bucket
if err := json.Unmarshal(body, &bucket); err != nil { if err := json.Unmarshal(body, &data); err != nil {
ch <- schema.Bucket{ID: bucket.ID, GlobalAliases: bucket.GlobalAliases} ch <- schema.Bucket{ID: bucket.ID, GlobalAliases: bucket.GlobalAliases}
return return
} }
ch <- bucket data.LocalAliases = bucket.LocalAliases
ch <- data
}() }()
} }
+14 -8
View File
@@ -1,14 +1,15 @@
package schema package schema
type GetBucketsRes struct { type GetBucketsRes struct {
ID string `json:"id"` ID string `json:"id"`
GlobalAliases []string `json:"globalAliases"` GlobalAliases []string `json:"globalAliases"`
LocalAliases []string `json:"localAliases"` LocalAliases []LocalAlias `json:"localAliases"`
} }
type Bucket struct { type Bucket struct {
ID string `json:"id"` ID string `json:"id"`
GlobalAliases []string `json:"globalAliases"` GlobalAliases []string `json:"globalAliases"`
LocalAliases []LocalAlias `json:"localAliases"`
WebsiteAccess bool `json:"websiteAccess"` WebsiteAccess bool `json:"websiteAccess"`
WebsiteConfig WebsiteConfig `json:"websiteConfig"` WebsiteConfig WebsiteConfig `json:"websiteConfig"`
Keys []KeyElement `json:"keys"` Keys []KeyElement `json:"keys"`
@@ -21,12 +22,17 @@ type Bucket struct {
Quotas Quotas `json:"quotas"` Quotas Quotas `json:"quotas"`
} }
type LocalAlias struct {
AccessKeyID string `json:"accessKeyId"`
Alias string `json:"alias"`
}
type KeyElement struct { type KeyElement struct {
AccessKeyID string `json:"accessKeyId"` AccessKeyID string `json:"accessKeyId"`
Name string `json:"name"` Name string `json:"name"`
Permissions Permissions `json:"permissions"` Permissions Permissions `json:"permissions"`
BucketLocalAliases []interface{} `json:"bucketLocalAliases"` BucketLocalAliases []string `json:"bucketLocalAliases"`
SecretAccessKey string `json:"secretAccessKey"` SecretAccessKey string `json:"secretAccessKey"`
} }
type Permissions struct { type Permissions struct {
+6 -12
View File
@@ -1,18 +1,12 @@
package schema package schema
type Config struct { type Config struct {
CompressionLevel int64 `json:"compression_level" toml:"compression_level"` RPCBindAddr string `json:"rpc_bind_addr" toml:"rpc_bind_addr"`
DataDir string `json:"data_dir" toml:"data_dir"` RPCPublicAddr string `json:"rpc_public_addr" toml:"rpc_public_addr"`
DBEngine string `json:"db_engine" toml:"db_engine"` RPCSecret string `json:"rpc_secret" toml:"rpc_secret"`
MetadataAutoSnapshotInterval string `json:"metadata_auto_snapshot_interval" toml:"metadata_auto_snapshot_interval"` Admin Admin `json:"admin" toml:"admin"`
MetadataDir string `json:"metadata_dir" toml:"metadata_dir"` S3API S3API `json:"s3_api" toml:"s3_api"`
ReplicationFactor int64 `json:"replication_factor" toml:"replication_factor"` S3Web S3Web `json:"s3_web" toml:"s3_web"`
RPCBindAddr string `json:"rpc_bind_addr" toml:"rpc_bind_addr"`
RPCPublicAddr string `json:"rpc_public_addr" toml:"rpc_public_addr"`
RPCSecret string `json:"rpc_secret" toml:"rpc_secret"`
Admin Admin `json:"admin" toml:"admin"`
S3API S3API `json:"s3_api" toml:"s3_api"`
S3Web S3Web `json:"s3_web" toml:"s3_web"`
} }
type Admin struct { type Admin struct {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "garage-webui", "name": "garage-webui",
"private": true, "private": true,
"version": "1.0.2", "version": "1.0.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev:client": "vite", "dev:client": "vite",
+1 -1
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"
> >
+2 -2
View File
@@ -4,7 +4,7 @@ import { readableBytes } from "@/lib/utils";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
type Props = { type Props = {
data: Bucket; data: Bucket & { aliases: string[] };
}; };
const BucketCard = ({ data }: Props) => { const BucketCard = ({ data }: Props) => {
@@ -15,7 +15,7 @@ const BucketCard = ({ data }: Props) => {
<ArchiveIcon size={28} className="shrink-0" /> <ArchiveIcon size={28} className="shrink-0" />
<p className="text-xl font-medium truncate"> <p className="text-xl font-medium truncate">
{data.globalAliases?.join(", ")} {data.aliases?.join(", ")}
</p> </p>
</div> </div>
+5 -2
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,
}); });
}; };
@@ -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 })
@@ -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>
); );
})} })}
@@ -84,7 +84,7 @@ const AllowKeyDialog = ({ currentKeys }: Props) => {
Allow Key Allow Key
</Button> </Button>
<Modal ref={dialogRef} backdrop open={isOpen}> <Modal ref={dialogRef} backdrop open={isOpen} className="max-w-2xl">
<Modal.Header className="mb-1">Allow Key</Modal.Header> <Modal.Header className="mb-1">Allow Key</Modal.Header>
<Modal.Body> <Modal.Body>
<p>Enter the key you want to allow access to.</p> <p>Enter the key you want to allow access to.</p>
@@ -100,6 +100,7 @@ const AllowKeyDialog = ({ currentKeys }: Props) => {
/> />
Key Key
</label> </label>
<label>Local Aliases</label>
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<Checkbox <Checkbox
color="primary" color="primary"
@@ -129,23 +130,31 @@ const AllowKeyDialog = ({ currentKeys }: Props) => {
<Table.Body> <Table.Body>
{!keyFields.length ? ( {!keyFields.length ? (
<tr> <tr>
<td colSpan={4} className="text-center"> <td colSpan={5} className="text-center">
No keys found No keys found
</td> </td>
</tr> </tr>
) : null} ) : null}
{keyFields.map((field, index) => ( {keyFields.map((field, index) => {
<Table.Row key={field.id}> const curKey = bucket.keys.find(
<CheckboxField (key) => key.accessKeyId === field.keyId
form={form} );
name={`keys.${index}.checked`} return (
label={field.name || field.keyId?.substring(0, 8)} <Table.Row key={field.id}>
/> <CheckboxField
<CheckboxField form={form} name={`keys.${index}.read`} /> form={form}
<CheckboxField form={form} name={`keys.${index}.write`} /> name={`keys.${index}.checked`}
<CheckboxField form={form} name={`keys.${index}.owner`} /> label={field.name || field.keyId?.substring(0, 8)}
</Table.Row> />
))} <span>
{curKey?.bucketLocalAliases?.join(", ") || "-"}
</span>
<CheckboxField form={form} name={`keys.${index}.read`} />
<CheckboxField form={form} name={`keys.${index}.write`} />
<CheckboxField form={form} name={`keys.${index}.owner`} />
</Table.Row>
);
})}
</Table.Body> </Table.Body>
</Table> </Table>
</div> </div>
@@ -50,6 +50,7 @@ const PermissionsTab = () => {
<Table.Head> <Table.Head>
<span>#</span> <span>#</span>
<span>Key</span> <span>Key</span>
<span>Aliases</span>
<span>Read</span> <span>Read</span>
<span>Write</span> <span>Write</span>
<span>Owner</span> <span>Owner</span>
@@ -61,6 +62,7 @@ const PermissionsTab = () => {
<Table.Row> <Table.Row>
<span>{idx + 1}</span> <span>{idx + 1}</span>
<span>{key.name || key.accessKeyId?.substring(0, 8)}</span> <span>{key.name || key.accessKeyId?.substring(0, 8)}</span>
<span>{key.bucketLocalAliases?.join(", ") || "-"}</span>
<span> <span>
<Checkbox <Checkbox
checked={key.permissions?.read} checked={key.permissions?.read}
+12 -5
View File
@@ -10,20 +10,27 @@ const BucketsPage = () => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const items = useMemo(() => { const items = useMemo(() => {
let buckets = data || []; let buckets =
data?.map((bucket) => {
return {
...bucket,
aliases: [
...(bucket.globalAliases || []),
...(bucket.localAliases?.map((l) => l.alias) || []),
],
};
}) || [];
if (search?.length > 0) { if (search?.length > 0) {
const q = search.toLowerCase(); const q = search.toLowerCase();
buckets = buckets.filter( buckets = buckets.filter(
(bucket) => (bucket) =>
bucket.id.includes(q) || bucket.id.includes(q) ||
bucket.globalAliases.find((alias) => alias.includes(q)) bucket.aliases.find((alias) => alias.includes(q))
); );
} }
buckets = buckets.sort((a, b) => buckets = buckets.sort((a, b) => a.aliases[0].localeCompare(b.aliases[0]));
a.globalAliases[0].localeCompare(b.globalAliases[0])
);
return buckets; return buckets;
}, [data, search]); }, [data, search]);
+7 -1
View File
@@ -5,6 +5,7 @@ export type GetBucketRes = Bucket[];
export type Bucket = { export type Bucket = {
id: string; id: string;
globalAliases: string[]; globalAliases: string[];
localAliases: LocalAlias[];
websiteAccess: boolean; websiteAccess: boolean;
websiteConfig?: WebsiteConfig | null; websiteConfig?: WebsiteConfig | null;
keys: Key[]; keys: Key[];
@@ -17,11 +18,16 @@ export type Bucket = {
quotas: Quotas; quotas: Quotas;
}; };
export type LocalAlias = {
accessKeyId: string;
alias: string;
};
export type Key = { export type Key = {
accessKeyId: string; accessKeyId: string;
name: string; name: string;
permissions: Permissions; permissions: Permissions;
bucketLocalAliases: any[]; bucketLocalAliases: string[];
}; };
export type Permissions = { export type Permissions = {
+5 -2
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 />
+1
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>