mirror of
https://github.com/khairul169/garage-webui.git
synced 2026-09-15 00:43:21 +07:00
feat: add cluster & bucket management
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { Bucket } from "../types";
|
||||
import { ArchiveIcon, ChartPie, ChartScatter } from "lucide-react";
|
||||
import { readableBytes } from "@/lib/utils";
|
||||
import Button from "@/components/ui/button";
|
||||
|
||||
type Props = {
|
||||
data: Bucket;
|
||||
};
|
||||
|
||||
const BucketCard = ({ data }: Props) => {
|
||||
return (
|
||||
<div className="card card-body p-6">
|
||||
<div className="flex flex-row items-start gap-4 p-2 pb-0">
|
||||
<ArchiveIcon size={28} />
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-xl font-medium">
|
||||
{data.globalAliases?.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm flex items-center gap-1">
|
||||
<ChartPie className="inline" size={16} />
|
||||
Usage
|
||||
</p>
|
||||
<p className="text-2xl font-medium">{readableBytes(data.bytes)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm flex items-center gap-1">
|
||||
<ChartScatter className="inline" size={16} />
|
||||
Objects
|
||||
</p>
|
||||
<p className="text-2xl font-medium">{data.objects}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-row justify-end gap-4">
|
||||
<Button href={`/buckets/${data.id}`}>Manage</Button>
|
||||
{/* <Button color="primary">Browse</Button> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BucketCard;
|
||||
@@ -0,0 +1,10 @@
|
||||
import api from "@/lib/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { GetBucketRes } from "./types";
|
||||
|
||||
export const useBuckets = () => {
|
||||
return useQuery({
|
||||
queryKey: ["buckets"],
|
||||
queryFn: () => api.get<GetBucketRes>("/buckets"),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Button } from "react-daisyui";
|
||||
import { Plus } from "lucide-react";
|
||||
import Chips from "@/components/ui/chips";
|
||||
import { Bucket } from "../../types";
|
||||
|
||||
type Props = {
|
||||
data: Bucket;
|
||||
};
|
||||
|
||||
const AliasesSection = ({ data }: Props) => {
|
||||
const aliases = data?.globalAliases?.slice(1);
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<p className="inline label label-text">Aliases</p>
|
||||
|
||||
<div className="flex flex-row flex-wrap gap-2 mt-1">
|
||||
{aliases?.map((alias: string) => (
|
||||
<Chips key={alias} onRemove={() => {}}>
|
||||
{alias}
|
||||
</Chips>
|
||||
))}
|
||||
<Button size="sm">
|
||||
<Plus className="-ml-1" size={18} />
|
||||
Add Alias
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AliasesSection;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Controller, DeepPartial, useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { QuotaSchema, quotaSchema } from "../schema";
|
||||
import { useEffect } from "react";
|
||||
import { Input, Toggle } from "react-daisyui";
|
||||
import FormControl from "@/components/ui/form-control";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useUpdateBucket } from "../hooks";
|
||||
import { Bucket } from "../../types";
|
||||
|
||||
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">
|
||||
<p className="label label-text py-0">Quotas</p>
|
||||
|
||||
<label className="inline-flex label label-text gap-2 cursor-pointer">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<Toggle {...(field as any)} checked={field.value} />
|
||||
)}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
|
||||
{isEnabled && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormControl
|
||||
form={form}
|
||||
name="maxObjects"
|
||||
title="Max Objects"
|
||||
render={(field) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
value={String(field.value || "")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormControl
|
||||
form={form}
|
||||
name="maxSize"
|
||||
title="Max Size (GB)"
|
||||
render={(field) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
value={String(field.value || "")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaSection;
|
||||
@@ -0,0 +1,51 @@
|
||||
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">
|
||||
<Card className="card-body gap-0 items-start">
|
||||
<Card.Title>Summary</Card.Title>
|
||||
|
||||
<AliasesSection data={data} />
|
||||
<WebsiteAccessSection data={data} />
|
||||
<QuotaSection data={data} />
|
||||
</Card>
|
||||
|
||||
<Card className="card-body">
|
||||
<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;
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Controller, DeepPartial, useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { websiteConfigSchema, WebsiteConfigSchema } from "../schema";
|
||||
import { useEffect } from "react";
|
||||
import { Input, Toggle } from "react-daisyui";
|
||||
import FormControl from "@/components/ui/form-control";
|
||||
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";
|
||||
|
||||
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>
|
||||
|
||||
<label className="inline-flex label label-text gap-2 cursor-pointer">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="websiteAccess"
|
||||
render={({ field }) => (
|
||||
<Toggle {...(field as any)} checked={field.value} />
|
||||
)}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
|
||||
{isEnabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormControl
|
||||
form={form}
|
||||
name="websiteConfig.indexDocument"
|
||||
title="Index Document"
|
||||
render={(field) => (
|
||||
<Input {...field} value={String(field.value || "")} />
|
||||
)}
|
||||
/>
|
||||
<FormControl
|
||||
form={form}
|
||||
name="websiteConfig.errorDocument"
|
||||
title="Error Document"
|
||||
render={(field) => (
|
||||
<Input {...field} value={String(field.value || "")} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 alert flex flex-row flex-wrap">
|
||||
<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;
|
||||
@@ -0,0 +1,19 @@
|
||||
import api from "@/lib/api";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Bucket } from "../types";
|
||||
|
||||
export const useBucket = (id?: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: ["bucket", id],
|
||||
queryFn: () => api.get<Bucket>("/v1/bucket", { params: { id } }),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateBucket = (id?: string | null) => {
|
||||
return useMutation({
|
||||
mutationFn: (values: any) => {
|
||||
return api.put<any>("/v1/bucket", { params: { id }, body: values });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useBucket } from "./hooks";
|
||||
import Page from "@/context/page-context";
|
||||
import TabView, { Tab } from "@/components/containers/tab-view";
|
||||
import { ChartLine, FolderSearch } from "lucide-react";
|
||||
import OverviewTab from "./components/overview-tab";
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{
|
||||
name: "overview",
|
||||
title: "Overview",
|
||||
icon: ChartLine,
|
||||
Component: OverviewTab,
|
||||
},
|
||||
// {
|
||||
// name: "browse",
|
||||
// title: "Browse",
|
||||
// icon: FolderSearch,
|
||||
// },
|
||||
];
|
||||
|
||||
const ManageBucketPage = () => {
|
||||
const { id } = useParams();
|
||||
const { data } = useBucket(id);
|
||||
|
||||
const name = data?.globalAliases[0];
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<Page title={name || "Manage Bucket"} prev="/buckets" />
|
||||
<TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageBucketPage;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const websiteConfigSchema = z.object({
|
||||
websiteAccess: z.boolean(),
|
||||
websiteConfig: z
|
||||
.object({ indexDocument: z.string(), errorDocument: z.string() })
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export type WebsiteConfigSchema = z.infer<typeof websiteConfigSchema>;
|
||||
|
||||
export const quotaSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
maxObjects: z.coerce.number().nullish(),
|
||||
maxSize: z.coerce.number().nullish(),
|
||||
});
|
||||
|
||||
export type QuotaSchema = z.infer<typeof quotaSchema>;
|
||||
@@ -0,0 +1,34 @@
|
||||
import Page from "@/context/page-context";
|
||||
import { useBuckets } from "./hooks";
|
||||
import { Button, Input } from "react-daisyui";
|
||||
import { Plus } from "lucide-react";
|
||||
import BucketCard from "./components/bucket-card";
|
||||
|
||||
const BucketsPage = () => {
|
||||
const { data } = useBuckets();
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<Page title="Buckets" />
|
||||
|
||||
<div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Input placeholder="Search..." />
|
||||
<div className="flex-1" />
|
||||
<Button color="primary">
|
||||
<Plus />
|
||||
Create Bucket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-8 items-stretch mt-4 md:mt-8">
|
||||
{data?.map((bucket) => (
|
||||
<BucketCard key={bucket.id} data={bucket} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BucketsPage;
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
|
||||
export type GetBucketRes = Bucket[];
|
||||
|
||||
export type Bucket = {
|
||||
id: string;
|
||||
globalAliases: string[];
|
||||
websiteAccess: boolean;
|
||||
websiteConfig?: WebsiteConfig | null;
|
||||
keys: Key[];
|
||||
objects: number;
|
||||
bytes: number;
|
||||
unfinishedUploads: number;
|
||||
unfinishedMultipartUploads: number;
|
||||
unfinishedMultipartUploadParts: number;
|
||||
unfinishedMultipartUploadBytes: number;
|
||||
quotas: Quotas;
|
||||
};
|
||||
|
||||
export type Key = {
|
||||
accessKeyId: string;
|
||||
name: string;
|
||||
permissions: Permissions;
|
||||
bucketLocalAliases: any[];
|
||||
};
|
||||
|
||||
export type Permissions = {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
owner: boolean;
|
||||
};
|
||||
|
||||
export type WebsiteConfig = {
|
||||
indexDocument: string;
|
||||
errorDocument: string;
|
||||
};
|
||||
|
||||
export type Quotas = {
|
||||
maxSize: null;
|
||||
maxObjects: null;
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { ConnectNodeSchema, connectNodeSchema } from "../schema";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Plug } from "lucide-react";
|
||||
|
||||
const ConnectNodeDialog = () => {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
@@ -45,6 +46,7 @@ const ConnectNodeDialog = () => {
|
||||
return (
|
||||
<>
|
||||
<Button color="primary" onClick={handleShow}>
|
||||
<Plug />
|
||||
Connect
|
||||
</Button>
|
||||
|
||||
@@ -58,7 +60,7 @@ const ConnectNodeDialog = () => {
|
||||
<Modal.Header>Connect Node</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>Run this command to get node id:</p>
|
||||
<Code className="mt-2">docker exec -it garage /garage node id</Code>
|
||||
<Code className="mt-2">docker exec garage /garage node id</Code>
|
||||
|
||||
<p className="mt-8">Enter node id:</p>
|
||||
<Input
|
||||
|
||||
@@ -7,7 +7,7 @@ const ClusterPage = () => {
|
||||
const { data } = useClusterStatus();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="container">
|
||||
<Page title="Cluster" />
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -17,7 +17,7 @@ const HomePage = () => {
|
||||
const { data: health } = useNodesHealth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="container">
|
||||
<Page title="Dashboard" />
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
|
||||
@@ -27,7 +27,11 @@ const HomePage = () => {
|
||||
value={ucfirst(health?.status)}
|
||||
valueClassName={cn(
|
||||
"text-lg",
|
||||
health?.status === "healthy" ? "text-success" : "text-error"
|
||||
health?.status === "healthy"
|
||||
? "text-success"
|
||||
: health?.status === "degraded"
|
||||
? "text-warning"
|
||||
: "text-error"
|
||||
)}
|
||||
/>
|
||||
<StatsCard title="Nodes" icon={HardDrive} value={health?.knownNodes} />
|
||||
|
||||
Reference in New Issue
Block a user