feat: add bucket object browser

This commit is contained in:
2024-08-18 22:57:08 +07:00
parent 3a147f4133
commit 934e0c409c
29 changed files with 656 additions and 18 deletions
@@ -1,168 +0,0 @@
import Button from "@/components/ui/button";
import { useKeys } from "@/pages/keys/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus } from "lucide-react";
import { useEffect } from "react";
import { Checkbox, Modal, Table } from "react-daisyui";
import { useFieldArray, useForm } from "react-hook-form";
import { AllowKeysSchema, allowKeysSchema } from "../schema";
import { useDisclosure } from "@/hooks/useDisclosure";
import { CheckboxField } from "@/components/ui/checkbox";
import { useAllowKey } from "../hooks";
import { toast } from "sonner";
import { handleError } from "@/lib/utils";
import { useQueryClient } from "@tanstack/react-query";
type Props = {
id?: string;
currentKeys?: string[];
};
const AllowKeyDialog = ({ id, currentKeys }: Props) => {
const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
const { data: keys } = useKeys();
const form = useForm<AllowKeysSchema>({
resolver: zodResolver(allowKeysSchema),
});
const { fields: keyFields } = useFieldArray({
control: form.control,
name: "keys",
});
const queryClient = useQueryClient();
const allowKey = useAllowKey(id, {
onSuccess: () => {
form.reset();
onClose();
toast.success("Key allowed!");
queryClient.invalidateQueries({ queryKey: ["bucket", id] });
},
onError: handleError,
});
useEffect(() => {
const _keys = keys
?.filter((key) => !currentKeys?.includes(key.id))
?.map((key) => ({
checked: false,
keyId: key.id,
name: key.name,
read: false,
write: false,
owner: false,
}));
form.setValue("keys", _keys || []);
}, [keys, currentKeys]);
const onToggleAll = (
e: React.ChangeEvent<HTMLInputElement>,
field: keyof AllowKeysSchema["keys"][number]
) => {
const curValues = form.getValues("keys");
const newValues = curValues.map((item) => ({
...item,
[field]: e.target.checked,
}));
form.setValue("keys", newValues);
};
const onSubmit = form.handleSubmit((values) => {
const data = values.keys
.filter((key) => key.checked)
.map((key) => ({
keyId: key.keyId,
permissions: { read: key.read, write: key.write, owner: key.owner },
}));
allowKey.mutate(data);
});
return (
<>
<Button icon={Plus} color="primary" onClick={onOpen}>
Allow Key
</Button>
<Modal ref={dialogRef} backdrop open={isOpen}>
<Modal.Header className="mb-1">Allow Key</Modal.Header>
<Modal.Body>
<p>Enter the key you want to allow access to.</p>
<div className="overflow-x-auto mt-4">
<Table>
<Table.Head>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
color="primary"
size="sm"
onChange={(e) => onToggleAll(e, "checked")}
/>
Key
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
color="primary"
size="sm"
onChange={(e) => onToggleAll(e, "read")}
/>
Read
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
color="primary"
size="sm"
onChange={(e) => onToggleAll(e, "write")}
/>
Write
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
color="primary"
size="sm"
onChange={(e) => onToggleAll(e, "owner")}
/>
Owner
</label>
</Table.Head>
<Table.Body>
{!keyFields.length ? (
<tr>
<td colSpan={4} className="text-center">
No keys found
</td>
</tr>
) : null}
{keyFields.map((field, index) => (
<Table.Row key={field.id}>
<CheckboxField
form={form}
name={`keys.${index}.checked`}
label={field.name || field.keyId?.substring(0, 8)}
/>
<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>
</div>
</Modal.Body>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>
<Button
color="primary"
disabled={allowKey.isPending}
onClick={onSubmit}
>
Submit
</Button>
</Modal.Actions>
</Modal>
</>
);
};
export default AllowKeyDialog;
@@ -1,111 +0,0 @@
import { Modal } from "react-daisyui";
import { Plus } from "lucide-react";
import Chips from "@/components/ui/chips";
import { Bucket } from "../../types";
import { useDisclosure } from "@/hooks/useDisclosure";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AddAliasSchema, addAliasSchema } from "../schema";
import Button from "@/components/ui/button";
import { useAddAlias, useRemoveAlias } from "../hooks";
import { toast } from "sonner";
import { handleError } from "@/lib/utils";
import { InputField } from "@/components/ui/input";
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
type Props = {
data?: Bucket;
};
const AliasesSection = ({ data }: Props) => {
const queryClient = useQueryClient();
const removeAlias = useRemoveAlias(data?.id, {
onSuccess: () => {
toast.success("Alias removed!");
queryClient.invalidateQueries({ queryKey: ["bucket", data?.id] });
},
onError: handleError,
});
const onRemoveAlias = (alias: string) => {
if (window.confirm("Are you sure you want to remove this alias?")) {
removeAlias.mutate(alias);
}
};
const aliases = data?.globalAliases || [];
return (
<div className="mt-2">
<p className="inline label label-text">Aliases</p>
<div className="flex flex-row flex-wrap gap-2 mt-2">
{aliases.map((alias: string) => (
<Chips key={alias} onRemove={() => onRemoveAlias(alias)}>
{alias}
</Chips>
))}
<AddAliasDialog id={data?.id} />
</div>
</div>
);
};
const AddAliasDialog = ({ id }: { id?: string }) => {
const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
const form = useForm<AddAliasSchema>({
resolver: zodResolver(addAliasSchema),
defaultValues: { alias: "" },
});
const queryClient = useQueryClient();
useEffect(() => {
if (isOpen) form.setFocus("alias");
}, [isOpen]);
const addAlias = useAddAlias(id, {
onSuccess: () => {
form.reset();
onClose();
toast.success("Alias added!");
queryClient.invalidateQueries({ queryKey: ["bucket", id] });
},
onError: handleError,
});
const onSubmit = form.handleSubmit((values) => {
addAlias.mutate(values.alias);
});
return (
<>
<Button size="sm" onClick={onOpen} icon={Plus}>
Add Alias
</Button>
<Modal ref={dialogRef} open={isOpen}>
<Modal.Header>Add Alias</Modal.Header>
<Modal.Body>
<form onSubmit={onSubmit}>
<InputField form={form} name="alias" title="Name" />
</form>
</Modal.Body>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>
<Button
color="primary"
onClick={onSubmit}
disabled={addAlias.isPending}
>
Submit
</Button>
</Modal.Actions>
</Modal>
</>
);
};
export default AliasesSection;
@@ -1,75 +0,0 @@
import { DeepPartial, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { QuotaSchema, quotaSchema } from "../schema";
import { useEffect } from "react";
import { useDebounce } from "@/hooks/useDebounce";
import { useUpdateBucket } from "../hooks";
import { Bucket } from "../../types";
import { InputField } from "@/components/ui/input";
import { ToggleField } from "@/components/ui/toggle";
type Props = {
data?: Bucket;
};
const QuotaSection = ({ data }: Props) => {
const form = useForm<QuotaSchema>({
resolver: zodResolver(quotaSchema),
});
const isEnabled = useWatch({ control: form.control, name: "enabled" });
const updateMutation = useUpdateBucket(data?.id);
const onChange = useDebounce((values: DeepPartial<QuotaSchema>) => {
const { enabled } = values;
const maxObjects = Number(values.maxObjects);
const maxSize = Math.round(Number(values.maxSize) * 1024 * 1024);
const data = {
maxObjects: enabled && maxObjects > 0 ? maxObjects : null,
maxSize: enabled && maxSize > 0 ? maxSize : null,
};
updateMutation.mutate({ quotas: data });
});
useEffect(() => {
form.reset({
enabled:
data?.quotas?.maxSize != null || data?.quotas?.maxObjects != null,
maxSize: data?.quotas?.maxSize
? data?.quotas?.maxSize / 1024 / 1024
: null,
maxObjects: data?.quotas?.maxObjects || null,
});
const { unsubscribe } = form.watch((values) => onChange(values));
return unsubscribe;
}, [data]);
return (
<div className="mt-8">
<ToggleField form={form} name="enabled" title="Quotas" label="Enabled" />
{isEnabled && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
form={form}
name="maxObjects"
title="Max Objects"
type="number"
/>
<InputField
form={form}
name="maxSize"
title="Max Size (GB)"
type="number"
/>
</div>
)}
</div>
);
};
export default QuotaSection;
@@ -1,51 +0,0 @@
import { Card } from "react-daisyui";
import { useParams } from "react-router-dom";
import { useBucket } from "../hooks";
import { ChartPie, ChartScatter } from "lucide-react";
import { readableBytes } from "@/lib/utils";
import WebsiteAccessSection from "./overview-website-access";
import AliasesSection from "./overview-aliases";
import QuotaSection from "./overview-quota";
const OverviewTab = () => {
const { id } = useParams();
const { data } = useBucket(id);
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-8 items-start">
<Card className="card-body gap-0 items-start order-2 md:order-1">
<Card.Title>Summary</Card.Title>
<AliasesSection data={data} />
<WebsiteAccessSection data={data} />
<QuotaSection data={data} />
</Card>
<Card className="card-body order-1 md:order-2">
<Card.Title>Usage</Card.Title>
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="flex flex-row gap-3">
<ChartPie className="mt-1" size={20} />
<div className="flex-1">
<p className="text-sm flex items-center gap-1">Storage</p>
<p className="text-2xl font-medium">
{readableBytes(data?.bytes)}
</p>
</div>
</div>
<div className="flex flex-row gap-3">
<ChartScatter className="mt-1" size={20} />
<div className="flex-1">
<p className="text-sm flex items-center gap-1">Objects</p>
<p className="text-2xl font-medium">{data?.objects}</p>
</div>
</div>
</div>
</Card>
</div>
);
};
export default OverviewTab;
@@ -1,128 +0,0 @@
import { DeepPartial, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { websiteConfigSchema, WebsiteConfigSchema } from "../schema";
import { useEffect } from "react";
import { useDebounce } from "@/hooks/useDebounce";
import { useUpdateBucket } from "../hooks";
import { useConfig } from "@/hooks/useConfig";
import { Info, LinkIcon } from "lucide-react";
import Button from "@/components/ui/button";
import { Bucket } from "../../types";
import { InputField } from "@/components/ui/input";
import { ToggleField } from "@/components/ui/toggle";
type Props = {
data?: Bucket;
};
const WebsiteAccessSection = ({ data }: Props) => {
const { data: config } = useConfig();
const form = useForm<WebsiteConfigSchema>({
resolver: zodResolver(websiteConfigSchema),
});
const bucketName = data?.globalAliases[0] || "";
const isEnabled = useWatch({ control: form.control, name: "websiteAccess" });
const websitePort = config?.s3_web?.bind_addr?.split(":").pop() || "80";
const rootDomain = config?.s3_web?.root_domain;
const updateMutation = useUpdateBucket(data?.id);
const onChange = useDebounce((values: DeepPartial<WebsiteConfigSchema>) => {
const data = {
enabled: values.websiteAccess,
indexDocument: values.websiteAccess
? values.websiteConfig?.indexDocument
: undefined,
errorDocument: values.websiteAccess
? values.websiteConfig?.errorDocument
: undefined,
};
updateMutation.mutate({
websiteAccess: data,
});
});
useEffect(() => {
form.reset({
websiteAccess: data?.websiteAccess,
websiteConfig: {
indexDocument: data?.websiteConfig?.indexDocument || "index.html",
errorDocument: data?.websiteConfig?.errorDocument || "error/400.html",
},
});
const { unsubscribe } = form.watch((values) => onChange(values));
return unsubscribe;
}, [data]);
return (
<div className="mt-8">
<div className="flex flex-row gap-2">
<p className="label label-text py-0 grow-0">Website Access</p>
<Button
href="https://garagehq.deuxfleurs.fr/documentation/cookbook/exposing-websites"
target="_blank"
size="sm"
shape="circle"
color="ghost"
>
<Info size={16} />
</Button>
</div>
<ToggleField form={form} name="websiteAccess" label="Enabled" />
{isEnabled && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
form={form}
name="websiteConfig.indexDocument"
title="Index Document"
/>
<InputField
form={form}
name="websiteConfig.errorDocument"
title="Error Document"
/>
</div>
<div className="mt-4 alert flex flex-row flex-wrap text-sm gap-x-2 gap-y-1">
<a
href={`http://${bucketName}`}
className="inline-flex items-center flex-row gap-2 font-medium hover:link"
target="_blank"
>
<LinkIcon size={14} />
{bucketName}
</a>
{rootDomain ? (
<>
<a
href={`http://${bucketName}${rootDomain}`}
className="inline-flex items-center flex-row gap-2 font-medium hover:link"
target="_blank"
>
<LinkIcon size={14} />
{bucketName + rootDomain}
</a>
<a
href={`http://${bucketName}${rootDomain}:${websitePort}`}
className="inline-flex items-center flex-row gap-2 font-medium hover:link"
target="_blank"
>
<LinkIcon size={14} />
{bucketName + rootDomain + ":" + websitePort}
</a>
</>
) : null}
</div>
</>
)}
</div>
);
};
export default WebsiteAccessSection;
@@ -1,103 +0,0 @@
import { useParams } from "react-router-dom";
import { useBucket, useDenyKey } from "../hooks";
import { Card, Checkbox, Table } from "react-daisyui";
import Button from "@/components/ui/button";
import { Trash } from "lucide-react";
import AllowKeyDialog from "./allow-key-dialog";
import { useMemo } from "react";
import { toast } from "sonner";
import { handleError } from "@/lib/utils";
const PermissionsTab = () => {
const { id } = useParams();
const { data, refetch } = useBucket(id);
const denyKey = useDenyKey(id, {
onSuccess: () => {
toast.success("Key removed!");
refetch();
},
onError: handleError,
});
const keys = useMemo(() => {
return data?.keys.filter(
(key) =>
key.permissions.read !== false ||
key.permissions.write !== false ||
key.permissions.owner !== false
);
}, [data?.keys]);
const onRemove = (id: string) => {
if (window.confirm("Are you sure you want to remove this key?")) {
denyKey.mutate({
keyId: id,
permissions: { read: true, write: true, owner: true },
});
}
};
return (
<div>
<Card className="card-body">
<div className="flex flex-row items-center gap-2">
<Card.Title className="flex-1 truncate">Access Keys</Card.Title>
<AllowKeyDialog
id={id}
currentKeys={keys?.map((key) => key.accessKeyId)}
/>
</div>
<div className="overflow-x-auto">
<Table zebra size="sm">
<Table.Head>
<span>#</span>
<span>Key</span>
<span>Read</span>
<span>Write</span>
<span>Owner</span>
<span />
</Table.Head>
<Table.Body>
{keys?.map((key, idx) => (
<Table.Row>
<span>{idx + 1}</span>
<span>{key.name || key.accessKeyId?.substring(0, 8)}</span>
<span>
<Checkbox
checked={key.permissions?.read}
color="primary"
className="cursor-default"
/>
</span>
<span>
<Checkbox
checked={key.permissions?.write}
color="primary"
className="cursor-default"
/>
</span>
<span>
<Checkbox
checked={key.permissions?.owner}
color="primary"
className="cursor-default"
/>
</span>
<Button
icon={Trash}
onClick={() => onRemove(key.accessKeyId)}
/>
</Table.Row>
))}
</Table.Body>
</Table>
</div>
</Card>
</div>
);
};
export default PermissionsTab;