diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..57c5b3f --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,40 @@ +# GitHub Release Workflow + +This document describes the GitHub Actions workflow for releasing the Garage UI project. + +## How to Create a Release + +1. Create a new tag following semantic versioning and push it to GitHub: + ```bash + git tag v1.0.0 + git push origin v1.0.0 + ``` + +2. The workflow will automatically create a GitHub release and build everything + +## What the Workflow Does + +When a new tag is pushed, the workflow will: + +1. Extract the version from the tag (e.g., v1.0.0 becomes 1.0.0) +2. Build the Docker image and push it to GitHub Container Registry with appropriate version tags +3. Build binaries for various platforms (Linux with architectures: 386, amd64, arm, arm64) using the tag version +4. Create a GitHub release and attach the binaries as assets + +## Docker Images + +The Docker images will be available at: +- `ghcr.io/khairul169/garage-webui:latest` +- `ghcr.io/khairul169/garage-webui:X.Y.Z` (version tag) +- `ghcr.io/khairul169/garage-webui:X.Y` (major.minor tag) + +## Binaries + +The binaries will be attached to the GitHub release and can be downloaded directly from the release page. + +## Configuration + +If you want to also push Docker images to Docker Hub, uncomment and configure the Docker Hub login section in the workflow file and add the following secrets to your repository: + +- `DOCKERHUB_USERNAME`: Your Docker Hub username +- `DOCKERHUB_TOKEN`: Your Docker Hub access token diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fdf92fc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,156 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + DOCKER_HUB_REGISTRY: khairul169/garage-webui + +jobs: + build-and-push-image: + name: Build and push Docker image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Optional: Log in to Docker Hub if you want to push there too + # Uncomment and configure secrets in your GitHub repository settings + # - name: Log in to Docker Hub + # uses: docker/login-action@v3 + # with: + # username: ${{ secrets.DOCKERHUB_USERNAME }} + # password: ${{ secrets.DOCKERHUB_TOKEN }} + # + # - name: Add Docker Hub as additional image target + # if: ${{ success() && secrets.DOCKERHUB_USERNAME != '' }} + # run: | + # docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.get_version.outputs.VERSION }} ${{ env.DOCKER_HUB_REGISTRY }}:${{ steps.get_version.outputs.VERSION }} + # docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.get_version.outputs.VERSION }} ${{ env.DOCKER_HUB_REGISTRY }}:latest + # docker push ${{ env.DOCKER_HUB_REGISTRY }}:${{ steps.get_version.outputs.VERSION }} + # docker push ${{ env.DOCKER_HUB_REGISTRY }}:latest + + - name: Get version from tag + id: get_version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=${{ steps.get_version.outputs.VERSION }} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.get_version.outputs.VERSION }} + cache-from: type=gha + cache-to: type=gha,mode=max + + build-binaries: + name: Build binaries + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Set up PNPM + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Install dependencies + run: pnpm install + + - name: Get version from tag + id: get_version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Create version.txt file with tag version + run: echo "${{ steps.get_version.outputs.VERSION }}" > version.txt + + - name: Create custom build script for tag-based versioning + run: | + cat > misc/tag-build.sh << 'EOF' + #!/bin/sh + + set -e + + BINARY=garage-webui + VERSION=$(cat version.txt) + PLATFORMS="linux" + ARCHITECTURES="386 amd64 arm arm64" + + echo "Building version $VERSION" + + pnpm run build + cd backend && rm -rf dist && mkdir -p dist && rm -rf ./ui/dist && cp -r ../dist ./ui/dist + + for PLATFORM in $PLATFORMS; do + for ARCH in $ARCHITECTURES; do + echo "Building $PLATFORM-$ARCH" + + GOOS=$PLATFORM GOARCH=$ARCH go build -o "dist/$BINARY-v$VERSION-$PLATFORM-$ARCH" -tags="prod" main.go + done + done + EOF + + chmod +x misc/tag-build.sh + + - name: Build binaries + run: | + ./misc/tag-build.sh + + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v1 + with: + name: Release ${{ steps.get_version.outputs.VERSION }} + draft: false + prerelease: false + files: | + backend/dist/garage-webui-* + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index c128b51..1c3c636 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ dist-ssr .env* !.env.example docker-compose.*.yml +!docker-compose.dev.yml data/ meta/ diff --git a/LICENSE b/LICENSE index 302d07b..cff9c6f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Khairul Hidayat +Copyright (c) 2024-2025 Khairul Hidayat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index ed0c42e..539d549 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![image](misc/img/garage-webui.png)](misc/img/garage-webui.png) -A simple admin web UI for [Garage](https://garagehq.deuxfleurs.fr/), a self-hosted, S3-compatible, distributed object storage service. +A modern admin web UI for [Garage v2](https://garagehq.deuxfleurs.fr/), a self-hosted, S3-compatible, distributed object storage service. [ [Screenshots](misc/SCREENSHOTS.md) | [Install Garage](https://garagehq.deuxfleurs.fr/documentation/quick-start/) | [Garage Git](https://git.deuxfleurs.fr/Deuxfleurs/garage) ] @@ -21,7 +21,7 @@ The Garage Web UI is available as a single executable binary and docker image. Y ### Docker CLI ```sh -$ docker run -p 3909:3909 -v ./garage.toml:/etc/garage.toml:ro --restart unless-stopped --name garage-webui khairul169/garage-webui:latest +docker run -p 3909:3909 -v ./garage.toml:/etc/garage.toml:ro --restart unless-stopped --name garage-webui ghcr.io/khairul169/garage-webui:latest ``` ### Docker Compose @@ -45,7 +45,7 @@ services: - 3903:3903 webui: - image: khairul169/garage-webui:latest + image: ghcr.io/khairul169/garage-webui:latest container_name: garage-webui restart: unless-stopped volumes: @@ -62,21 +62,21 @@ services: Get the latest binary from the [release page](https://github.com/khairul169/garage-webui/releases/latest) according to your OS architecture. For example: ```sh -$ wget -O garage-webui https://github.com/khairul169/garage-webui/releases/download/1.1.0/garage-webui-v1.1.0-linux-amd64 -$ chmod +x garage-webui -$ sudo cp garage-webui /usr/local/bin +wget -O garage-webui https://github.com/khairul169/garage-webui/releases/download/1.1.0/garage-webui-v1.1.0-linux-amd64 +chmod +x garage-webui +sudo cp garage-webui /usr/local/bin ``` Run the program with specified `garage.toml` config path. ```sh -$ CONFIG_PATH=./garage.toml garage-webui +CONFIG_PATH=./garage.toml garage-webui ``` If you want to run the program at startup, you may want to create a systemd service. ```sh -$ sudo nano /etc/systemd/system/garage-webui.service +sudo nano /etc/systemd/system/garage-webui.service ``` ``` @@ -97,27 +97,26 @@ WantedBy=default.target Then reload and start the garage-webui service. ```sh -$ sudo systemctl daemon-reload -$ sudo systemctl enable --now garage-webui +sudo systemctl daemon-reload +sudo systemctl enable --now garage-webui ``` ### Configuration To simplify installation, the Garage Web UI uses values from the Garage configuration, such as `rpc_public_addr`, `admin.admin_token`, `s3_web.root_domain`, etc. -Example content of `config.toml`: +Example content of `garage.toml` for Garage v2: ```toml metadata_dir = "/var/lib/garage/meta" data_dir = "/var/lib/garage/data" db_engine = "sqlite" -metadata_auto_snapshot_interval = "6h" replication_factor = 3 compression_level = 2 rpc_bind_addr = "[::]:3901" -rpc_public_addr = "localhost:3901" # Required +rpc_public_addr = "localhost:3901" # Required for Web UI rpc_secret = "YOUR_RPC_SECRET_HERE" [s3_api] @@ -130,7 +129,7 @@ bind_addr = "[::]:3902" root_domain = ".web.domain.com" index = "index.html" -[admin] # Required +[admin] # Required for Web UI api_bind_addr = "[::]:3903" admin_token = "YOUR_ADMIN_TOKEN_HERE" metrics_token = "YOUR_METRICS_TOKEN_HERE" @@ -181,29 +180,38 @@ This project is bootstrapped using TypeScript & React for the UI, and Go for bac ### Setup ```sh -$ git clone https://github.com/khairul169/garage-webui.git -$ cd garage-webui && pnpm install -$ cd backend && pnpm install && cd .. +git clone https://github.com/khairul169/garage-webui.git +cd garage-webui && pnpm install +cd backend && pnpm install && cd .. ``` -### Running +### Development with Docker -Start both the client and server concurrently: +For development with Docker, a `docker-compose.dev.yml` file is provided with 4 Garage v2 instances: ```sh -$ pnpm run dev # or npm run dev +# Create necessary directories for Garage data +mkdir -p dev.local/data-garage/meta dev.local/data-garage/data +mkdir -p dev.local/data-garage2/meta dev.local/data-garage2/data +mkdir -p dev.local/data-garage3/meta dev.local/data-garage3/data +mkdir -p dev.local/data-garage4/meta dev.local/data-garage4/data + +# Generate a secure RPC secret using OpenSSL +# The rpc_secret is used to secure communication between Garage nodes +RPC_SECRET=$(openssl rand -hex 32) +echo "Generated RPC secret: $RPC_SECRET" + +# Copy the template configuration files and replace CONTAINER_NAME with the actual container name +# Using sed with empty string after -i for macOS compatibility +cp garage.toml.template dev.local/garage.toml && sed -i '' "s/CONTAINER_NAME/garage/g; s/dev-garage-secret/$RPC_SECRET/g" dev.local/garage.toml +cp garage.toml.template dev.local/garage2.toml && sed -i '' "s/CONTAINER_NAME/garage2/g; s/dev-garage-secret/$RPC_SECRET/g" dev.local/garage2.toml +cp garage.toml.template dev.local/garage3.toml && sed -i '' "s/CONTAINER_NAME/garage3/g; s/dev-garage-secret/$RPC_SECRET/g" dev.local/garage3.toml +cp garage.toml.template dev.local/garage4.toml && sed -i '' "s/CONTAINER_NAME/garage4/g; s/dev-garage-secret/$RPC_SECRET/g" dev.local/garage4.toml + +# Setup environment variables +cp .env.example .env +cp backend/.env.example backend/.env + +# Start the Garage containers +docker-compose -f docker-compose.dev.yml up -d ``` - -Or start each instance separately: - -```sh -$ pnpm run dev:client -$ cd backend -$ pnpm run dev:server -``` - -## Troubleshooting - -Make sure you are using the latest version of Garage. If the data cannot be loaded, please check whether your instance of Garage has the admin API enabled and the ports are accessible. - -If you encounter any problems, please do not hesitate to submit an issue [here](https://github.com/khairul169/garage-webui/issues). You can describe the problem and attach the error logs. diff --git a/backend/.env.example b/backend/.env.example index f5fa4af..881b174 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,6 +1,9 @@ # BASE_PATH="" -AUTH_USER_PASS='username:$2y$10$DSTi9o0uQPEHSNlf66xMEOgm9KgVNBP3vHxA3SK0Xha2EVMb3mTXm' -API_BASE_URL="http://garage:3903" -S3_ENDPOINT_URL="http://garage:3900" -API_ADMIN_KEY="" +AUTH_USER_PASS='admin:$2y$10$2i1DScIpTap7oB6KEYLP7um9/ms6LBf.TBzuqfSWRdRMvWRe35Y0S' +API_BASE_URL="http://localhost:3903" +S3_ENDPOINT_URL="http://localhost:3900" +S3_REGION=garage +API_ADMIN_KEY="dev-admin-token" +PORT=3909 +CONFIG_PATH="../dev.local/garage.toml" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..89e9e1b --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,57 @@ +services: + garage: + image: dxflrs/garage:v2.0.0 + container_name: garage + hostname: garage + volumes: + - ./dev.local/garage.toml:/etc/garage.toml + - ./dev.local/data-garage/meta:/var/lib/garage/meta + - ./dev.local/data-garage/data:/var/lib/garage/data + restart: unless-stopped + ports: + - 3900:3900 + - 3901:3901 + - 3902:3902 + - 3903:3903 + garage2: + image: dxflrs/garage:v2.0.0 + container_name: garage2 + hostname: garage2 + volumes: + - ./dev.local/garage2.toml:/etc/garage.toml + - ./dev.local/data-garage2/meta:/var/lib/garage/meta + - ./dev.local/data-garage2/data:/var/lib/garage/data + restart: unless-stopped + ports: + - 3900 + - 3901 + - 3902 + - 3903 + garage3: + image: dxflrs/garage:v2.0.0 + container_name: garage3 + hostname: garage3 + volumes: + - ./dev.local/garage3.toml:/etc/garage.toml + - ./dev.local/data-garage3/meta:/var/lib/garage/meta + - ./dev.local/data-garage3/data:/var/lib/garage/data + restart: unless-stopped + ports: + - 3900 + - 3901 + - 3902 + - 3903 + garage4: + image: dxflrs/garage:v2.0.0 + container_name: garage4 + hostname: garage4 + volumes: + - ./dev.local/garage4.toml:/etc/garage.toml + - ./dev.local/data-garage4/meta:/var/lib/garage/meta + - ./dev.local/data-garage4/data:/var/lib/garage/data + restart: unless-stopped + ports: + - 3900 + - 3901 + - 3902 + - 3903 diff --git a/docker-compose.yml b/docker-compose.yml index 0a233bb..a314487 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: - 3903:3903 webui: - image: khairul169/garage-webui:latest + image: ghcr.io/khairul169/garage-webui:latest container_name: garage-webui restart: unless-stopped volumes: diff --git a/garage.toml.template b/garage.toml.template new file mode 100644 index 0000000..c54da6a --- /dev/null +++ b/garage.toml.template @@ -0,0 +1,26 @@ +data_dir = "/var/lib/garage/data" +db_engine = "lmdb" +metadata_auto_snapshot_interval = "6h" +metadata_dir = "/var/lib/garage/meta" + +compression_level = 2 +replication_factor = 3 + +rpc_bind_addr = "[::]:3901" +rpc_public_addr = "CONTAINER_NAME:3901" +rpc_secret = "dev-garage-secret" + +[s3_api] +api_bind_addr = "[::]:3900" +root_domain = ".s3.garage.local" +s3_region = "garage" + +[s3_web] +bind_addr = "[::]:3902" +index = "index.html" +root_domain = ".web.garage.local" + +[admin] +admin_token = "dev-admin-token" +api_bind_addr = "[::]:3903" +metrics_token = "dev-metrics-token" diff --git a/src/components/layouts/main-layout.tsx b/src/components/layouts/main-layout.tsx index 5f5c261..a7f0148 100644 --- a/src/components/layouts/main-layout.tsx +++ b/src/components/layouts/main-layout.tsx @@ -1,5 +1,5 @@ import { PageContext } from "@/context/page-context"; -import { Suspense, useContext, useEffect } from "react"; +import { Suspense, useContext, useEffect, useRef } from "react"; import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom"; import Sidebar from "../containers/sidebar"; import { ArrowLeft, MenuIcon } from "lucide-react"; @@ -13,9 +13,12 @@ const MainLayout = () => { const { pathname } = useLocation(); const auth = useAuth(); + const sidebarRef = useRef(sidebar); + sidebarRef.current = sidebar; + useEffect(() => { - if (sidebar.isOpen) { - sidebar.onClose(); + if (sidebarRef.current.isOpen) { + sidebarRef.current.onClose(); } }, [pathname]); diff --git a/src/components/ui/chips.tsx b/src/components/ui/chips.tsx index c061636..b466c47 100644 --- a/src/components/ui/chips.tsx +++ b/src/components/ui/chips.tsx @@ -9,17 +9,42 @@ type Props = React.ComponentPropsWithoutRef<"div"> & { }; const Chips = forwardRef( - ({ className, children, onRemove, ...props }, ref) => { - const Comp = props.onClick ? "button" : "div"; + ({ className, children, onRemove, onClick, ...props }, ref) => { + const commonProps = { + ref: ref as never, + className: cn( + "inline-flex flex-row items-center h-8 px-4 rounded-full text-sm border border-primary/80 text-base-content cursor-default", + className + ), + }; + + if (onClick) { + return ( + + ) : null} + + ); + } return ( - {children} {onRemove ? ( @@ -33,7 +58,7 @@ const Chips = forwardRef( ) : null} - + ); } ); diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 7df41da..00d25ab 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -8,7 +8,7 @@ type Props = ComponentPropsWithoutRef & { onCreateOption?: (inputValue: string) => void; }; -const Select = forwardRef(({ creatable, ...props }, ref) => { +const Select = forwardRef, Props>(({ creatable, ...props }, ref) => { const Comp = creatable ? Creatable : BaseSelect; return ( diff --git a/src/context/page-context.tsx b/src/context/page-context.tsx index 444518f..b8bce45 100644 --- a/src/context/page-context.tsx +++ b/src/context/page-context.tsx @@ -5,6 +5,8 @@ import React, { useCallback, useContext, useEffect, + useMemo, + useRef, useState, } from "react"; @@ -16,8 +18,8 @@ type PageContextValues = { export const PageContext = createContext< | (PageContextValues & { - setValue: (values: Partial) => void; - }) + setValue: (values: Partial) => void; + }) | null >(null); @@ -34,8 +36,13 @@ export const PageContextProvider = ({ children }: PropsWithChildren) => { setValues((prev) => ({ ...prev, ...value })); }, []); + const contextValue = useMemo(() => ({ + ...values, + setValue + }), [values, setValue]); + return ( - + ); }; @@ -47,13 +54,18 @@ const Page = memo((props: PageProps) => { throw new Error("Page component must be used within a PageContextProvider"); } - useEffect(() => { - context.setValue(props); + const setValueRef = useRef(context.setValue); + setValueRef.current = context.setValue; + useEffect(() => { + setValueRef.current(props); + }, [props]); + + useEffect(() => { return () => { - context.setValue(initialValues); + setValueRef.current(initialValues); }; - }, [props, context.setValue]); + }, []); return null; }); diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index d302fcb..8f41fbc 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -1,20 +1,20 @@ import { useCallback, useRef } from "react"; -export const useDebounce = void>( - fn: T, +export const useDebounce = ( + fn: (...args: Args) => void, delay: number = 500 ) => { const timerRef = useRef(null); const debouncedFn = useCallback( - (...args: any[]) => { + (...args: Args) => { if (timerRef.current) { clearTimeout(timerRef.current); } timerRef.current = setTimeout(() => fn(...args), delay); }, - [fn] + [fn, delay] ); - return debouncedFn as T; + return debouncedFn; }; diff --git a/src/hooks/useDisclosure.ts b/src/hooks/useDisclosure.ts index 453fe91..0b73a30 100644 --- a/src/hooks/useDisclosure.ts +++ b/src/hooks/useDisclosure.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; -export const useDisclosure = () => { +export const useDisclosure = () => { const dialogRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [data, setData] = useState(null); diff --git a/src/lib/api.ts b/src/lib/api.ts index db03c9a..6cc7fde 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,9 +2,9 @@ import * as utils from "@/lib/utils"; import { BASE_PATH } from "./consts"; type FetchOptions = Omit & { - params?: Record; + params?: Record; headers?: Record; - body?: any; + body?: BodyInit | Record | unknown[] | null; }; export const API_URL = BASE_PATH + "/api"; @@ -20,7 +20,7 @@ export class APIError extends Error { } const api = { - async fetch(url: string, options?: Partial) { + async fetch(url: string, options?: Partial) { const headers: Record = {}; const _url = new URL(API_URL + url, window.location.origin); @@ -30,16 +30,27 @@ const api = { }); } - if ( - typeof options?.body === "object" && - !(options.body instanceof FormData) - ) { - options.body = JSON.stringify(options.body); - headers["Content-Type"] = "application/json"; + let body: BodyInit | null | undefined = undefined; + if (options?.body) { + if ( + (typeof options.body === "object" && !Array.isArray(options.body) && + !(options.body instanceof FormData) && + !(options.body instanceof URLSearchParams) && + !(options.body instanceof ReadableStream) && + !(options.body instanceof ArrayBuffer) && + !(options.body instanceof Blob)) || + Array.isArray(options.body) + ) { + body = JSON.stringify(options.body); + headers["Content-Type"] = "application/json"; + } else { + body = options.body as BodyInit; + } } const res = await fetch(_url, { ...options, + body, credentials: "include", headers: { ...headers, ...(options?.headers || {}) }, }); @@ -66,28 +77,28 @@ const api = { return data as unknown as T; }, - async get(url: string, options?: Partial) { + async get(url: string, options?: Partial) { return this.fetch(url, { ...options, method: "GET", }); }, - async post(url: string, options?: Partial) { + async post(url: string, options?: Partial) { return this.fetch(url, { ...options, method: "POST", }); }, - async put(url: string, options?: Partial) { + async put(url: string, options?: Partial) { return this.fetch(url, { ...options, method: "PUT", }); }, - async delete(url: string, options?: Partial) { + async delete(url: string, options?: Partial) { return this.fetch(url, { ...options, method: "DELETE", diff --git a/src/lib/disclosure.ts b/src/lib/disclosure.ts index 5bf90ce..9157da3 100644 --- a/src/lib/disclosure.ts +++ b/src/lib/disclosure.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { createStore, useStore } from "zustand"; -export const createDisclosure = () => { +export const createDisclosure = () => { const store = createStore(() => ({ data: undefined as T | null, isOpen: false, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5ebd62c..2fa830a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -8,7 +8,7 @@ import { BASE_PATH } from "./consts"; dayjs.extend(dayjsRelativeTime); export { dayjs }; -export const cn = (...args: any[]) => { +export const cn = (...args: Parameters) => { return twMerge(clsx(...args)); }; diff --git a/src/pages/buckets/components/bucket-card.tsx b/src/pages/buckets/components/bucket-card.tsx index 6f5eee4..c27a2e6 100644 --- a/src/pages/buckets/components/bucket-card.tsx +++ b/src/pages/buckets/components/bucket-card.tsx @@ -2,12 +2,19 @@ import { Bucket } from "../types"; import { ArchiveIcon, ChartPie, ChartScatter } from "lucide-react"; import { readableBytes } from "@/lib/utils"; import Button from "@/components/ui/button"; +import { useBucketDetails } from "../hooks"; type Props = { data: Bucket & { aliases: string[] }; }; const BucketCard = ({ data }: Props) => { + const { data: bucketDetails } = useBucketDetails(data.id); + + // Use detailed data if available, otherwise fall back to basic data + const bytes = bucketDetails?.bytes ?? data.bytes; + const objects = bucketDetails?.objects ?? data.objects; + return (
@@ -25,7 +32,7 @@ const BucketCard = ({ data }: Props) => { Usage

- {readableBytes(data.bytes)} + {bytes != null ? readableBytes(bytes) : "n/a"}

@@ -34,7 +41,9 @@ const BucketCard = ({ data }: Props) => { Objects

-

{data.objects}

+

+ {objects != null ? objects.toLocaleString() : "n/a"} +

diff --git a/src/pages/buckets/components/create-bucket-dialog.tsx b/src/pages/buckets/components/create-bucket-dialog.tsx index 82e1e07..6851309 100644 --- a/src/pages/buckets/components/create-bucket-dialog.tsx +++ b/src/pages/buckets/components/create-bucket-dialog.tsx @@ -22,7 +22,7 @@ const CreateBucketDialog = () => { useEffect(() => { if (isOpen) form.setFocus("globalAlias"); - }, [isOpen]); + }, [isOpen, form]); const createBucket = useCreateBucket({ onSuccess: () => { diff --git a/src/pages/buckets/hooks.ts b/src/pages/buckets/hooks.ts index 70b4f11..587201e 100644 --- a/src/pages/buckets/hooks.ts +++ b/src/pages/buckets/hooks.ts @@ -3,19 +3,40 @@ import { useMutation, UseMutationOptions, useQuery, + useQueries, } from "@tanstack/react-query"; -import { GetBucketRes } from "./types"; +import { GetBucketRes, Bucket } from "./types"; import { CreateBucketSchema } from "./schema"; export const useBuckets = () => { return useQuery({ queryKey: ["buckets"], - queryFn: () => api.get("/buckets"), + queryFn: () => api.get("/v2/ListBuckets"), + }); +}; + +export const useBucketDetails = (id?: string | null) => { + return useQuery({ + queryKey: ["bucket-details", id], + queryFn: () => api.get("/v2/GetBucketInfo", { params: { id } }), + enabled: !!id, + }); +}; + +export const useBucketsWithDetails = () => { + const { data: buckets } = useBuckets(); + + return useQueries({ + queries: (buckets || []).map((bucket) => ({ + queryKey: ["bucket-details", bucket.id], + queryFn: () => api.get("/v2/GetBucketInfo", { params: { id: bucket.id } }), + enabled: !!bucket.id, + })), }); }; export const useCreateBucket = ( - options?: UseMutationOptions + options?: UseMutationOptions ) => { return useMutation({ mutationFn: (body) => api.post("/v2/CreateBucket", { body }), diff --git a/src/pages/buckets/manage/browse/actions.tsx b/src/pages/buckets/manage/browse/actions.tsx index fea7b84..5abcf79 100644 --- a/src/pages/buckets/manage/browse/actions.tsx +++ b/src/pages/buckets/manage/browse/actions.tsx @@ -86,7 +86,7 @@ const CreateFolderAction = ({ prefix }: CreateFolderActionProps) => { useEffect(() => { if (isOpen) form.setFocus("name"); - }, [isOpen]); + }, [isOpen, form]); const createFolder = usePutObject(bucketName, { onSuccess: () => { diff --git a/src/pages/buckets/manage/browse/browse-tab.tsx b/src/pages/buckets/manage/browse/browse-tab.tsx index 45e78d7..6ec4f94 100644 --- a/src/pages/buckets/manage/browse/browse-tab.tsx +++ b/src/pages/buckets/manage/browse/browse-tab.tsx @@ -30,7 +30,7 @@ const BrowseTab = () => { const newParams = new URLSearchParams(searchParams); newParams.set("prefix", prefix); setSearchParams(newParams); - }, [curPrefix]); + }, [curPrefix, prefixHistory, searchParams, setSearchParams]); const gotoPrefix = (prefix: string) => { const history = prefixHistory.slice(0, curPrefix + 1); diff --git a/src/pages/buckets/manage/browse/hooks.ts b/src/pages/buckets/manage/browse/hooks.ts index 203c049..4ddebc0 100644 --- a/src/pages/buckets/manage/browse/hooks.ts +++ b/src/pages/buckets/manage/browse/hooks.ts @@ -23,7 +23,7 @@ export const useBrowseObjects = ( export const usePutObject = ( bucket: string, - options?: UseMutationOptions + options?: UseMutationOptions ) => { return useMutation({ mutationFn: async (body) => { @@ -40,7 +40,7 @@ export const usePutObject = ( export const useDeleteObject = ( bucket: string, - options?: UseMutationOptions + options?: UseMutationOptions ) => { return useMutation({ mutationFn: (data) => diff --git a/src/pages/buckets/manage/browse/object-actions.tsx b/src/pages/buckets/manage/browse/object-actions.tsx index 97ed549..dd91423 100644 --- a/src/pages/buckets/manage/browse/object-actions.tsx +++ b/src/pages/buckets/manage/browse/object-actions.tsx @@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { handleError } from "@/lib/utils"; import { API_URL } from "@/lib/api"; -import { shareDialog } from "./share-dialog"; +import { shareDialog } from "./share-dialog-store"; type Props = { prefix?: string; @@ -36,8 +36,7 @@ const ObjectActions = ({ prefix = "", object, end }: Props) => { const onDelete = () => { if ( window.confirm( - `Are you sure you want to delete this ${ - isDirectory ? "directory and its content" : "object" + `Are you sure you want to delete this ${isDirectory ? "directory and its content" : "object" }?` ) ) { diff --git a/src/pages/buckets/manage/browse/share-dialog-store.ts b/src/pages/buckets/manage/browse/share-dialog-store.ts new file mode 100644 index 0000000..0cc0a88 --- /dev/null +++ b/src/pages/buckets/manage/browse/share-dialog-store.ts @@ -0,0 +1,3 @@ +import { createDisclosure } from "@/lib/disclosure"; + +export const shareDialog = createDisclosure<{ key: string; prefix: string }>(); diff --git a/src/pages/buckets/manage/browse/share-dialog.tsx b/src/pages/buckets/manage/browse/share-dialog.tsx index fc98e98..1bb6d7c 100644 --- a/src/pages/buckets/manage/browse/share-dialog.tsx +++ b/src/pages/buckets/manage/browse/share-dialog.tsx @@ -1,4 +1,3 @@ -import { createDisclosure } from "@/lib/disclosure"; import { Alert, Modal } from "react-daisyui"; import { useBucketContext } from "../context"; import { useConfig } from "@/hooks/useConfig"; @@ -8,8 +7,7 @@ import Button from "@/components/ui/button"; import { Copy, FileWarningIcon } from "lucide-react"; import { copyToClipboard } from "@/lib/utils"; import Checkbox from "@/components/ui/checkbox"; - -export const shareDialog = createDisclosure<{ key: string; prefix: string }>(); +import { shareDialog } from "./share-dialog-store"; const ShareDialog = () => { const { isOpen, data, dialogRef } = shareDialog.use(); @@ -26,12 +24,12 @@ const ShareDialog = () => { bucketName + rootDomain, bucketName + rootDomain + `:${websitePort}`, ], - [bucketName, config?.s3_web] + [bucketName, rootDomain, websitePort] ); useEffect(() => { setDomain(bucketName); - }, [domains]); + }, [bucketName]); const url = "http://" + domain + "/" + data?.prefix + data?.key; @@ -60,6 +58,7 @@ const ShareDialog = () => { value={url} className="w-full pr-12" onFocus={(e) => e.target.select()} + readOnly />