diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d7bb055 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,3 @@ +# App +API_BASE_URL=http://localhost:3903 +CONFIG_PATH=/app/garage/garage.toml diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..04a419d --- /dev/null +++ b/backend/README.md @@ -0,0 +1,15 @@ +# backend + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.1.18. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/backend/lib/api.ts b/backend/lib/api.ts new file mode 100644 index 0000000..5074d76 --- /dev/null +++ b/backend/lib/api.ts @@ -0,0 +1,83 @@ +import { config } from "./garage"; + +type FetchOptions = Omit & { + params?: Record; + headers?: Record; + body?: any; +}; + +const adminPort = config?.admin?.api_bind_addr?.split(":").pop(); +const adminAddr = + import.meta.env.API_BASE_URL || + config?.rpc_public_addr?.split(":")[0] + ":" + adminPort || + ""; + +export const API_BASE_URL = + !adminAddr.startsWith("http") && !adminAddr.startsWith("https") + ? `http://${adminAddr}` + : adminAddr; + +export const API_ADMIN_KEY = + import.meta.env.API_ADMIN_KEY || config?.admin?.admin_token; + +const api = { + async fetch(url: string, options?: Partial) { + const headers: Record = { + Authorization: `Bearer ${API_ADMIN_KEY}`, + }; + const _url = new URL(API_BASE_URL + url); + + if (options?.params) { + Object.entries(options.params).forEach(([key, value]) => { + _url.searchParams.set(key, String(value)); + }); + } + + if ( + typeof options?.body === "object" && + !(options.body instanceof FormData) + ) { + options.body = JSON.stringify(options.body); + headers["Content-Type"] = "application/json"; + } + + const res = await fetch(_url, { + ...options, + headers: { ...headers, ...(options?.headers || {}) }, + }); + + if (!res.ok) { + const err = new Error(res.statusText); + (err as any).status = res.status; + throw err; + } + + const isJson = res.headers + .get("Content-Type") + ?.includes("application/json"); + + if (isJson) { + const json = (await res.json()) as T; + return json; + } + + const text = await res.text(); + return text as unknown as T; + }, + + async get(url: string, options?: Partial) { + return this.fetch(url, { + ...options, + method: "GET", + }); + }, + + async post(url: string, options?: Partial) { + return this.fetch(url, { + ...options, + method: "POST", + }); + }, +}; + +export default api; diff --git a/backend/lib/garage.ts b/backend/lib/garage.ts new file mode 100644 index 0000000..07a37a3 --- /dev/null +++ b/backend/lib/garage.ts @@ -0,0 +1,4 @@ +import type { Config } from "../types/garage"; +import { readTomlFile } from "./utils"; + +export const config = readTomlFile(process.env.CONFIG_PATH); diff --git a/backend/lib/proxy-api.ts b/backend/lib/proxy-api.ts new file mode 100644 index 0000000..4da488d --- /dev/null +++ b/backend/lib/proxy-api.ts @@ -0,0 +1,35 @@ +import type { Context } from "hono"; +import { API_ADMIN_KEY, API_BASE_URL } from "./api"; + +export const proxyApi = async (c: Context) => { + const url = new URL(c.req.url); + const reqUrl = new URL(API_BASE_URL + url.pathname + url.search); + + try { + const headers = c.req.raw.headers; + let body: BodyInit | ReadableStream | null = c.req.raw.body; + + headers.set("authorization", `Bearer ${API_ADMIN_KEY}`); + + if (headers.get("content-type")?.includes("application/json")) { + const json = await c.req.json(); + body = JSON.stringify(json); + } + + const res = await fetch(reqUrl, { + ...c.req.raw, + method: c.req.method, + headers, + body, + }); + return res; + } catch (err) { + return c.json( + { + success: false, + error: (err as Error)?.message || "Server error", + }, + 500 + ); + } +}; diff --git a/backend/lib/utils.ts b/backend/lib/utils.ts new file mode 100644 index 0000000..df2a108 --- /dev/null +++ b/backend/lib/utils.ts @@ -0,0 +1,9 @@ +import fs from "node:fs"; +import toml from "toml"; + +export const readTomlFile = (path?: string | null) => { + if (!path || !fs.existsSync(path)) { + return undefined; + } + return toml.parse(fs.readFileSync(path, "utf8")) as T; +}; diff --git a/backend/main.ts b/backend/main.ts new file mode 100644 index 0000000..1239c6f --- /dev/null +++ b/backend/main.ts @@ -0,0 +1,23 @@ +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import router from "./routes"; +import { proxyApi } from "./lib/proxy-api"; + +const HOST = import.meta.env.HOST || "0.0.0.0"; +const PORT = Number(import.meta.env.PORT) || 3909; + +const app = new Hono(); + +app.use(logger()); + +// API router +app.route("/", router); + +// Proxy to garage admin API +app.all("*", proxyApi); + +export default { + fetch: app.fetch, + hostname: HOST, + port: PORT, +}; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..718b63a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,18 @@ +{ + "name": "backend", + "module": "main.ts", + "type": "module", + "scripts": { + "dev": "bun --watch main.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "hono": "^4.5.5", + "toml": "^3.0.0" + } +} \ No newline at end of file diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml new file mode 100644 index 0000000..9828792 --- /dev/null +++ b/backend/pnpm-lock.yaml @@ -0,0 +1,69 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + hono: + specifier: ^4.5.5 + version: 4.5.5 + toml: + specifier: ^3.0.0 + version: 3.0.0 + devDependencies: + '@types/bun': + specifier: latest + version: 1.1.6 + +packages: + + '@types/bun@1.1.6': + resolution: {integrity: sha512-uJgKjTdX0GkWEHZzQzFsJkWp5+43ZS7HC8sZPFnOwnSo1AsNl2q9o2bFeS23disNDqbggEgyFkKCHl/w8iZsMA==} + + '@types/node@20.12.14': + resolution: {integrity: sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==} + + '@types/ws@8.5.12': + resolution: {integrity: sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==} + + bun-types@1.1.17: + resolution: {integrity: sha512-Z4+OplcSd/YZq7ZsrfD00DKJeCwuNY96a1IDJyR73+cTBaFIS7SC6LhpY/W3AMEXO9iYq5NJ58WAwnwL1p5vKg==} + + hono@4.5.5: + resolution: {integrity: sha512-fXBXHqaVfimWofbelLXci8pZyIwBMkDIwCa4OwZvK+xVbEyYLELVP4DfbGaj1aEM6ZY3hHgs4qLvCO2ChkhgQw==} + engines: {node: '>=16.0.0'} + + toml@3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + +snapshots: + + '@types/bun@1.1.6': + dependencies: + bun-types: 1.1.17 + + '@types/node@20.12.14': + dependencies: + undici-types: 5.26.5 + + '@types/ws@8.5.12': + dependencies: + '@types/node': 20.12.14 + + bun-types@1.1.17: + dependencies: + '@types/node': 20.12.14 + '@types/ws': 8.5.12 + + hono@4.5.5: {} + + toml@3.0.0: {} + + undici-types@5.26.5: {} diff --git a/backend/routes/buckets.ts b/backend/routes/buckets.ts new file mode 100644 index 0000000..0d0989e --- /dev/null +++ b/backend/routes/buckets.ts @@ -0,0 +1,19 @@ +import { Hono } from "hono"; +import api from "../lib/api"; + +export const buckets = new Hono() + + /** + * Get all buckets + */ + .get("/", async (c) => { + const data = await api.get("/v1/bucket?list"); + + const buckets = await Promise.all( + data.map(async (bucket: any) => { + return api.get("/v1/bucket", { params: { id: bucket.id } }); + }) + ); + + return c.json(buckets); + }); diff --git a/backend/routes/config.ts b/backend/routes/config.ts new file mode 100644 index 0000000..0870986 --- /dev/null +++ b/backend/routes/config.ts @@ -0,0 +1,21 @@ +import { Hono } from "hono"; +import { config } from "../lib/garage"; + +export const configRoute = new Hono() + + /** + * Get garage config + */ + .get("/", async (c) => { + const data = { + ...(config || {}), + rpc_secret: undefined, + admin: { + ...(config?.admin || {}), + admin_token: undefined, + metrics_token: undefined, + }, + }; + + return c.json(data); + }); diff --git a/backend/routes/index.ts b/backend/routes/index.ts new file mode 100644 index 0000000..7577e8d --- /dev/null +++ b/backend/routes/index.ts @@ -0,0 +1,10 @@ +import { Hono } from "hono"; +import { buckets } from "./buckets"; +import { configRoute } from "./config"; + +const router = new Hono() + // + .route("/config", configRoute) + .route("/buckets", buckets); + +export default router; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/backend/types/garage.ts b/backend/types/garage.ts new file mode 100644 index 0000000..78a49eb --- /dev/null +++ b/backend/types/garage.ts @@ -0,0 +1,32 @@ +export type Config = { + metadata_dir: string; + data_dir: string; + db_engine: string; + metadata_auto_snapshot_interval: string; + replication_factor: number; + compression_level: number; + rpc_bind_addr: string; + rpc_public_addr: string; + rpc_secret: string; + s3_api?: S3API; + s3_web?: S3Web; + admin?: Admin; +}; + +export type Admin = { + api_bind_addr: string; + admin_token: string; + metrics_token: string; +}; + +export type S3API = { + s3_region: string; + api_bind_addr: string; + root_domain: string; +}; + +export type S3Web = { + bind_addr: string; + root_domain: string; + index: string; +}; diff --git a/src/app/router.tsx b/src/app/router.tsx index c39277f..455b821 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,8 +1,12 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import { lazy } from "react"; import AuthLayout from "@/components/layouts/auth-layout"; import MainLayout from "@/components/layouts/main-layout"; -import ClusterPage from "@/pages/cluster/page"; -import HomePage from "@/pages/home/page"; + +const ClusterPage = lazy(() => import("@/pages/cluster/page")); +const HomePage = lazy(() => import("@/pages/home/page")); +const BucketsPage = lazy(() => import("@/pages/buckets/page")); +const ManageBucketPage = lazy(() => import("@/pages/buckets/manage/page")); const router = createBrowserRouter([ { @@ -21,6 +25,13 @@ const router = createBrowserRouter([ path: "cluster", Component: ClusterPage, }, + { + path: "buckets", + children: [ + { index: true, Component: BucketsPage }, + { path: ":id", Component: ManageBucketPage }, + ], + }, ], }, ]); diff --git a/src/app/styles.css b/src/app/styles.css index ae30fad..8751453 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -5,7 +5,13 @@ @layer base { html, body { - @apply bg-base-200; + @apply bg-base-200 overflow-hidden; + } +} + +@layer utilities { + .container { + @apply max-w-5xl; } } diff --git a/src/components/containers/sidebar.tsx b/src/components/containers/sidebar.tsx new file mode 100644 index 0000000..9267aff --- /dev/null +++ b/src/components/containers/sidebar.tsx @@ -0,0 +1,40 @@ +import { Cylinder, HardDrive, LayoutDashboard } from "lucide-react"; +import { Menu } from "react-daisyui"; +import { Link } from "react-router-dom"; + +const Sidebar = () => { + return ( + + ); +}; + +export default Sidebar; diff --git a/src/components/containers/tab-view.tsx b/src/components/containers/tab-view.tsx new file mode 100644 index 0000000..91caff9 --- /dev/null +++ b/src/components/containers/tab-view.tsx @@ -0,0 +1,64 @@ +import { cn } from "@/lib/utils"; +import { LucideIcon } from "lucide-react"; +import { useMemo } from "react"; +import { Tabs } from "react-daisyui"; +import { useSearchParams } from "react-router-dom"; + +export type Tab = { + name: string; + title?: string; + icon?: LucideIcon; + Component?: () => JSX.Element; +}; + +type Props = { + tabs: Tab[]; + name?: string; + className?: string; + contentClassName?: string; +}; + +const TabView = ({ + tabs, + name = "tab", + className, + contentClassName, +}: Props) => { + const [searchParams, setSearchParams] = useSearchParams(); + const curTab = searchParams.get(name) || tabs[0].name; + + const content = useMemo(() => { + const Comp = tabs.find((tab) => tab.name === curTab)?.Component; + return Comp ? : null; + }, [curTab, tabs]); + + return ( + <> + + {tabs.map(({ icon: Icon, ...tab }) => ( + { + setSearchParams((params) => { + params.set(name, tab.name); + return params; + }); + }} + > + {Icon ? : null} + {tab.title || tab.name} + + ))} + + +
{content}
+ + ); +}; + +export default TabView; diff --git a/src/components/layouts/main-layout.tsx b/src/components/layouts/main-layout.tsx index f428789..ea87ca6 100644 --- a/src/components/layouts/main-layout.tsx +++ b/src/components/layouts/main-layout.tsx @@ -1,8 +1,9 @@ import { PageContext } from "@/context/page-context"; -import { useContext } from "react"; -import { Link, Outlet } from "react-router-dom"; -import { Menu } from "react-daisyui"; -import { HardDrive, LayoutDashboard } from "lucide-react"; +import { Suspense, useContext } from "react"; +import { Outlet, useNavigate } from "react-router-dom"; +import Sidebar from "../containers/sidebar"; +import { ArrowLeft } from "lucide-react"; +import Button from "../ui/button"; const MainLayout = () => { return ( @@ -13,50 +14,33 @@ const MainLayout = () => {
-
+ -
+
); }; -const Sidebar = () => { - return ( - - ); -}; - const Header = () => { const page = useContext(PageContext); + const navigate = useNavigate(); return ( -
-

{page?.title || "Dashboard"}

+
+ {page?.prev ? ( + + ) : null} +

{page?.title || "Dashboard"}

); }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..69180bc --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,23 @@ +import { ComponentPropsWithoutRef, forwardRef } from "react"; +import { Button as BaseButton } from "react-daisyui"; +import { Link } from "react-router-dom"; + +type ButtonProps = ComponentPropsWithoutRef & { + href?: string; + target?: "_blank" | "_self" | "_parent" | "_top"; +}; + +const Button = forwardRef( + ({ href, ...props }, ref) => { + return ( + + ); + } +); + +export default Button; diff --git a/src/components/ui/chips.tsx b/src/components/ui/chips.tsx new file mode 100644 index 0000000..c061636 --- /dev/null +++ b/src/components/ui/chips.tsx @@ -0,0 +1,41 @@ +import { cn } from "@/lib/utils"; +import { X } from "lucide-react"; +import React, { forwardRef } from "react"; +import { Button } from "react-daisyui"; + +type Props = React.ComponentPropsWithoutRef<"div"> & { + onClick?: () => void; + onRemove?: () => void; +}; + +const Chips = forwardRef( + ({ className, children, onRemove, ...props }, ref) => { + const Comp = props.onClick ? "button" : "div"; + + return ( + + {children} + {onRemove ? ( + + ) : null} + + ); + } +); + +export default Chips; diff --git a/src/context/page-context.tsx b/src/context/page-context.tsx index 6930d70..578d643 100644 --- a/src/context/page-context.tsx +++ b/src/context/page-context.tsx @@ -1,48 +1,59 @@ import { createContext, + memo, PropsWithChildren, + useCallback, useContext, useEffect, useState, } from "react"; type PageContextValues = { - title?: string | null; - setTitle: (title?: string | null) => void; + title: string | null; + prev: string | null; }; -export const PageContext = createContext(null); +export const PageContext = createContext< + | (PageContextValues & { + setValue: (values: Partial) => void; + }) + | null +>(null); + +const initialValues: PageContextValues = { + title: null, + prev: null, +}; export const PageContextProvider = ({ children }: PropsWithChildren) => { - const [title, setTitle] = useState(null); + const [values, setValues] = useState(initialValues); - const contextValues = { - title, - setTitle, - }; + const setValue = useCallback((value: Partial) => { + setValues((prev) => ({ ...prev, ...value })); + }, []); - return ; + return ( + + ); }; -type PageProps = { - title?: string; -}; +type PageProps = Partial; -const Page = ({ title }: PageProps) => { +const Page = memo((props: PageProps) => { const context = useContext(PageContext); if (!context) { throw new Error("Page component must be used within a PageContextProvider"); } useEffect(() => { - context.setTitle(title); + context.setValue(props); return () => { - context.setTitle(null); + context.setValue(initialValues); }; - }, [title, context.setTitle]); + }, [props, context.setValue]); return null; -}; +}); export default Page; diff --git a/src/hooks/useConfig.ts b/src/hooks/useConfig.ts new file mode 100644 index 0000000..dd7b522 --- /dev/null +++ b/src/hooks/useConfig.ts @@ -0,0 +1,10 @@ +import api from "@/lib/api"; +import { Config } from "@/types/garage"; +import { useQuery } from "@tanstack/react-query"; + +export const useConfig = () => { + return useQuery({ + queryKey: ["config"], + queryFn: () => api.get("/config"), + }); +}; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..d302fcb --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,20 @@ +import { useCallback, useRef } from "react"; + +export const useDebounce = void>( + fn: T, + delay: number = 500 +) => { + const timerRef = useRef(null); + + const debouncedFn = useCallback( + (...args: any[]) => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(() => fn(...args), delay); + }, + [fn] + ); + + return debouncedFn as T; +}; diff --git a/src/lib/api.ts b/src/lib/api.ts index fdae9cc..2496e48 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4,8 +4,6 @@ type FetchOptions = Omit & { body?: any; }; -const ADMIN_KEY = "E1tDBf4mhc/XMHq1YJkDE6N1j3AZG9dRWR+vDDTyASk="; - const api = { async fetch(url: string, options?: Partial) { const headers: Record = {}; @@ -25,10 +23,6 @@ const api = { headers["Content-Type"] = "application/json"; } - if (ADMIN_KEY) { - headers["Authorization"] = `Bearer ${ADMIN_KEY}`; - } - const res = await fetch(_url, { ...options, headers: { ...headers, ...(options?.headers || {}) }, @@ -64,6 +58,20 @@ const api = { method: "POST", }); }, + + async put(url: string, options?: Partial) { + return this.fetch(url, { + ...options, + method: "PUT", + }); + }, + + async delete(url: string, options?: Partial) { + return this.fetch(url, { + ...options, + method: "DELETE", + }); + }, }; export default api; diff --git a/src/pages/buckets/components/bucket-card.tsx b/src/pages/buckets/components/bucket-card.tsx new file mode 100644 index 0000000..7de1b76 --- /dev/null +++ b/src/pages/buckets/components/bucket-card.tsx @@ -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 ( +
+
+ + +
+

+ {data.globalAliases?.join(", ")} +

+
+ +
+

+ + Usage +

+

{readableBytes(data.bytes)}

+
+ +
+

+ + Objects +

+

{data.objects}

+
+
+ +
+ + {/* */} +
+
+ ); +}; + +export default BucketCard; diff --git a/src/pages/buckets/hooks.ts b/src/pages/buckets/hooks.ts new file mode 100644 index 0000000..e9b4018 --- /dev/null +++ b/src/pages/buckets/hooks.ts @@ -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("/buckets"), + }); +}; diff --git a/src/pages/buckets/manage/components/overview-aliases.tsx b/src/pages/buckets/manage/components/overview-aliases.tsx new file mode 100644 index 0000000..77ed4d7 --- /dev/null +++ b/src/pages/buckets/manage/components/overview-aliases.tsx @@ -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 ( +
+

Aliases

+ +
+ {aliases?.map((alias: string) => ( + {}}> + {alias} + + ))} + +
+
+ ); +}; + +export default AliasesSection; diff --git a/src/pages/buckets/manage/components/overview-quota.tsx b/src/pages/buckets/manage/components/overview-quota.tsx new file mode 100644 index 0000000..4a54ec5 --- /dev/null +++ b/src/pages/buckets/manage/components/overview-quota.tsx @@ -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({ + resolver: zodResolver(quotaSchema), + }); + const isEnabled = useWatch({ control: form.control, name: "enabled" }); + + const updateMutation = useUpdateBucket(data?.id); + + const onChange = useDebounce((values: DeepPartial) => { + 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 ( +
+

Quotas

+ + + + {isEnabled && ( +
+ ( + + )} + /> + ( + + )} + /> +
+ )} +
+ ); +}; + +export default QuotaSection; diff --git a/src/pages/buckets/manage/components/overview-tab.tsx b/src/pages/buckets/manage/components/overview-tab.tsx new file mode 100644 index 0000000..c423e9b --- /dev/null +++ b/src/pages/buckets/manage/components/overview-tab.tsx @@ -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 ( +
+ + Summary + + + + + + + + Usage + +
+
+ +
+

Storage

+

+ {readableBytes(data?.bytes)} +

+
+
+ +
+ +
+

Objects

+

{data?.objects}

+
+
+
+
+
+ ); +}; + +export default OverviewTab; diff --git a/src/pages/buckets/manage/components/overview-website-access.tsx b/src/pages/buckets/manage/components/overview-website-access.tsx new file mode 100644 index 0000000..77d64ee --- /dev/null +++ b/src/pages/buckets/manage/components/overview-website-access.tsx @@ -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({ + 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) => { + 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 ( +
+
+

Website Access

+ +
+ + + + {isEnabled && ( + <> +
+ ( + + )} + /> + ( + + )} + /> +
+ + + + )} +
+ ); +}; + +export default WebsiteAccessSection; diff --git a/src/pages/buckets/manage/hooks.ts b/src/pages/buckets/manage/hooks.ts new file mode 100644 index 0000000..fe15ebb --- /dev/null +++ b/src/pages/buckets/manage/hooks.ts @@ -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("/v1/bucket", { params: { id } }), + enabled: !!id, + }); +}; + +export const useUpdateBucket = (id?: string | null) => { + return useMutation({ + mutationFn: (values: any) => { + return api.put("/v1/bucket", { params: { id }, body: values }); + }, + }); +}; diff --git a/src/pages/buckets/manage/page.tsx b/src/pages/buckets/manage/page.tsx new file mode 100644 index 0000000..f9489a9 --- /dev/null +++ b/src/pages/buckets/manage/page.tsx @@ -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 ( +
+ + +
+ ); +}; + +export default ManageBucketPage; diff --git a/src/pages/buckets/manage/schema.ts b/src/pages/buckets/manage/schema.ts new file mode 100644 index 0000000..e18afea --- /dev/null +++ b/src/pages/buckets/manage/schema.ts @@ -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; + +export const quotaSchema = z.object({ + enabled: z.boolean(), + maxObjects: z.coerce.number().nullish(), + maxSize: z.coerce.number().nullish(), +}); + +export type QuotaSchema = z.infer; diff --git a/src/pages/buckets/page.tsx b/src/pages/buckets/page.tsx new file mode 100644 index 0000000..831cfba --- /dev/null +++ b/src/pages/buckets/page.tsx @@ -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 ( +
+ + +
+
+ +
+ +
+ +
+ {data?.map((bucket) => ( + + ))} +
+
+
+ ); +}; + +export default BucketsPage; diff --git a/src/pages/buckets/types.ts b/src/pages/buckets/types.ts new file mode 100644 index 0000000..79761d6 --- /dev/null +++ b/src/pages/buckets/types.ts @@ -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; +}; diff --git a/src/pages/cluster/components/connect-node-dialog.tsx b/src/pages/cluster/components/connect-node-dialog.tsx index 11bc461..4bf9c24 100644 --- a/src/pages/cluster/components/connect-node-dialog.tsx +++ b/src/pages/cluster/components/connect-node-dialog.tsx @@ -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(null); @@ -45,6 +46,7 @@ const ConnectNodeDialog = () => { return ( <> @@ -58,7 +60,7 @@ const ConnectNodeDialog = () => { Connect Node

Run this command to get node id:

- docker exec -it garage /garage node id + docker exec garage /garage node id

Enter node id:

{ const { data } = useClusterStatus(); return ( -
+
diff --git a/src/pages/home/page.tsx b/src/pages/home/page.tsx index 7471e5c..98cfef5 100644 --- a/src/pages/home/page.tsx +++ b/src/pages/home/page.tsx @@ -17,7 +17,7 @@ const HomePage = () => { const { data: health } = useNodesHealth(); return ( -
+
@@ -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" )} /> diff --git a/src/types/garage.ts b/src/types/garage.ts new file mode 100644 index 0000000..78a49eb --- /dev/null +++ b/src/types/garage.ts @@ -0,0 +1,32 @@ +export type Config = { + metadata_dir: string; + data_dir: string; + db_engine: string; + metadata_auto_snapshot_interval: string; + replication_factor: number; + compression_level: number; + rpc_bind_addr: string; + rpc_public_addr: string; + rpc_secret: string; + s3_api?: S3API; + s3_web?: S3Web; + admin?: Admin; +}; + +export type Admin = { + api_bind_addr: string; + admin_token: string; + metrics_token: string; +}; + +export type S3API = { + s3_region: string; + api_bind_addr: string; + root_domain: string; +}; + +export type S3Web = { + bind_addr: string; + root_domain: string; + index: string; +};