mirror of
https://github.com/khairul169/garage-webui.git
synced 2025-10-14 14:59:32 +07:00
Merge aaef5d15840aaddda04a16bcb5a535a75630f24a into ee420fbf2946e9f79977615cee5e29192d7da478
This commit is contained in:
commit
a2d339e7d1
40
.github/workflows/README.md
vendored
Normal file
40
.github/workflows/README.md
vendored
Normal file
@ -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
|
156
.github/workflows/release.yml
vendored
Normal file
156
.github/workflows/release.yml
vendored
Normal file
@ -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 }}
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -26,6 +26,7 @@ dist-ssr
|
||||
.env*
|
||||
!.env.example
|
||||
docker-compose.*.yml
|
||||
!docker-compose.dev.yml
|
||||
|
||||
data/
|
||||
meta/
|
||||
|
2
LICENSE
2
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
|
||||
|
76
README.md
76
README.md
@ -2,7 +2,7 @@
|
||||
|
||||
[](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.
|
||||
|
@ -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"
|
||||
|
57
docker-compose.dev.yml
Normal file
57
docker-compose.dev.yml
Normal file
@ -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
|
@ -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:
|
||||
|
26
garage.toml.template
Normal file
26
garage.toml.template
Normal file
@ -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"
|
@ -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]);
|
||||
|
||||
|
@ -9,17 +9,21 @@ type Props = React.ComponentPropsWithoutRef<"div"> & {
|
||||
};
|
||||
|
||||
const Chips = forwardRef<HTMLDivElement, Props>(
|
||||
({ className, children, onRemove, ...props }, ref) => {
|
||||
const Comp = props.onClick ? "button" : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref as never}
|
||||
className={cn(
|
||||
({ 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
|
||||
)}
|
||||
{...(props as any)}
|
||||
),
|
||||
};
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
{...commonProps}
|
||||
onClick={onClick}
|
||||
{...(props as React.ComponentPropsWithoutRef<"button">)}
|
||||
>
|
||||
{children}
|
||||
{onRemove ? (
|
||||
@ -33,7 +37,28 @@ const Chips = forwardRef<HTMLDivElement, Props>(
|
||||
<X size={16} />
|
||||
</Button>
|
||||
) : null}
|
||||
</Comp>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...commonProps}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{onRemove ? (
|
||||
<Button
|
||||
color="ghost"
|
||||
shape="circle"
|
||||
size="sm"
|
||||
className="-mr-3"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -8,7 +8,7 @@ type Props = ComponentPropsWithoutRef<typeof BaseSelect> & {
|
||||
onCreateOption?: (inputValue: string) => void;
|
||||
};
|
||||
|
||||
const Select = forwardRef<any, Props>(({ creatable, ...props }, ref) => {
|
||||
const Select = forwardRef<React.ComponentRef<typeof BaseSelect>, Props>(({ creatable, ...props }, ref) => {
|
||||
const Comp = creatable ? Creatable : BaseSelect;
|
||||
|
||||
return (
|
||||
|
@ -5,6 +5,8 @@ import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
@ -34,8 +36,13 @@ export const PageContextProvider = ({ children }: PropsWithChildren) => {
|
||||
setValues((prev) => ({ ...prev, ...value }));
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
...values,
|
||||
setValue
|
||||
}), [values, setValue]);
|
||||
|
||||
return (
|
||||
<PageContext.Provider children={children} value={{ ...values, setValue }} />
|
||||
<PageContext.Provider children={children} value={contextValue} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
@ -1,20 +1,20 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
export const useDebounce = <T extends (...args: any[]) => void>(
|
||||
fn: T,
|
||||
export const useDebounce = <Args extends unknown[]>(
|
||||
fn: (...args: Args) => void,
|
||||
delay: number = 500
|
||||
) => {
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(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;
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export const useDisclosure = <T = any>() => {
|
||||
export const useDisclosure = <T = unknown>() => {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [data, setData] = useState<T | null | undefined>(null);
|
||||
|
@ -2,9 +2,9 @@ import * as utils from "@/lib/utils";
|
||||
import { BASE_PATH } from "./consts";
|
||||
|
||||
type FetchOptions = Omit<RequestInit, "headers" | "body"> & {
|
||||
params?: Record<string, any>;
|
||||
params?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
body?: BodyInit | Record<string, unknown> | unknown[] | null;
|
||||
};
|
||||
|
||||
export const API_URL = BASE_PATH + "/api";
|
||||
@ -20,7 +20,7 @@ export class APIError extends Error {
|
||||
}
|
||||
|
||||
const api = {
|
||||
async fetch<T = any>(url: string, options?: Partial<FetchOptions>) {
|
||||
async fetch<T = unknown>(url: string, options?: Partial<FetchOptions>) {
|
||||
const headers: Record<string, string> = {};
|
||||
const _url = new URL(API_URL + url, window.location.origin);
|
||||
|
||||
@ -30,16 +30,27 @@ const api = {
|
||||
});
|
||||
}
|
||||
|
||||
let body: BodyInit | null | undefined = undefined;
|
||||
if (options?.body) {
|
||||
if (
|
||||
typeof options?.body === "object" &&
|
||||
!(options.body instanceof FormData)
|
||||
(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)
|
||||
) {
|
||||
options.body = JSON.stringify(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<T = any>(url: string, options?: Partial<FetchOptions>) {
|
||||
async get<T = unknown>(url: string, options?: Partial<FetchOptions>) {
|
||||
return this.fetch<T>(url, {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
},
|
||||
|
||||
async post<T = any>(url: string, options?: Partial<FetchOptions>) {
|
||||
async post<T = unknown>(url: string, options?: Partial<FetchOptions>) {
|
||||
return this.fetch<T>(url, {
|
||||
...options,
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async put<T = any>(url: string, options?: Partial<FetchOptions>) {
|
||||
async put<T = unknown>(url: string, options?: Partial<FetchOptions>) {
|
||||
return this.fetch<T>(url, {
|
||||
...options,
|
||||
method: "PUT",
|
||||
});
|
||||
},
|
||||
|
||||
async delete<T = any>(url: string, options?: Partial<FetchOptions>) {
|
||||
async delete<T = unknown>(url: string, options?: Partial<FetchOptions>) {
|
||||
return this.fetch<T>(url, {
|
||||
...options,
|
||||
method: "DELETE",
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createStore, useStore } from "zustand";
|
||||
|
||||
export const createDisclosure = <T = any>() => {
|
||||
export const createDisclosure = <T = unknown>() => {
|
||||
const store = createStore(() => ({
|
||||
data: undefined as T | null,
|
||||
isOpen: false,
|
||||
|
@ -8,7 +8,7 @@ import { BASE_PATH } from "./consts";
|
||||
dayjs.extend(dayjsRelativeTime);
|
||||
export { dayjs };
|
||||
|
||||
export const cn = (...args: any[]) => {
|
||||
export const cn = (...args: Parameters<typeof clsx>) => {
|
||||
return twMerge(clsx(...args));
|
||||
};
|
||||
|
||||
|
@ -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 (
|
||||
<div className="card card-body p-6">
|
||||
<div className="grid grid-cols-2 items-start gap-4 p-2 pb-0">
|
||||
@ -25,7 +32,7 @@ const BucketCard = ({ data }: Props) => {
|
||||
Usage
|
||||
</p>
|
||||
<p className="text-xl font-medium mt-1">
|
||||
{readableBytes(data.bytes)}
|
||||
{bytes != null ? readableBytes(bytes) : "n/a"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -34,7 +41,9 @@ const BucketCard = ({ data }: Props) => {
|
||||
<ChartScatter className="inline" size={16} />
|
||||
Objects
|
||||
</p>
|
||||
<p className="text-xl font-medium mt-1">{data.objects}</p>
|
||||
<p className="text-xl font-medium mt-1">
|
||||
{objects != null ? objects.toLocaleString() : "n/a"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -22,7 +22,7 @@ const CreateBucketDialog = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) form.setFocus("globalAlias");
|
||||
}, [isOpen]);
|
||||
}, [isOpen, form]);
|
||||
|
||||
const createBucket = useCreateBucket({
|
||||
onSuccess: () => {
|
||||
|
@ -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<GetBucketRes>("/buckets"),
|
||||
queryFn: () => api.get<GetBucketRes>("/v2/ListBuckets"),
|
||||
});
|
||||
};
|
||||
|
||||
export const useBucketDetails = (id?: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: ["bucket-details", id],
|
||||
queryFn: () => api.get<Bucket>("/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<Bucket>("/v2/GetBucketInfo", { params: { id: bucket.id } }),
|
||||
enabled: !!bucket.id,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateBucket = (
|
||||
options?: UseMutationOptions<any, Error, CreateBucketSchema>
|
||||
options?: UseMutationOptions<unknown, Error, CreateBucketSchema>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (body) => api.post("/v2/CreateBucket", { body }),
|
||||
|
@ -86,7 +86,7 @@ const CreateFolderAction = ({ prefix }: CreateFolderActionProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) form.setFocus("name");
|
||||
}, [isOpen]);
|
||||
}, [isOpen, form]);
|
||||
|
||||
const createFolder = usePutObject(bucketName, {
|
||||
onSuccess: () => {
|
||||
|
@ -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);
|
||||
|
@ -23,7 +23,7 @@ export const useBrowseObjects = (
|
||||
|
||||
export const usePutObject = (
|
||||
bucket: string,
|
||||
options?: UseMutationOptions<any, Error, PutObjectPayload>
|
||||
options?: UseMutationOptions<unknown, Error, PutObjectPayload>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: async (body) => {
|
||||
@ -40,7 +40,7 @@ export const usePutObject = (
|
||||
|
||||
export const useDeleteObject = (
|
||||
bucket: string,
|
||||
options?: UseMutationOptions<any, Error, { key: string; recursive?: boolean }>
|
||||
options?: UseMutationOptions<unknown, Error, { key: string; recursive?: boolean }>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (data) =>
|
||||
|
@ -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"
|
||||
}?`
|
||||
)
|
||||
) {
|
||||
|
3
src/pages/buckets/manage/browse/share-dialog-store.ts
Normal file
3
src/pages/buckets/manage/browse/share-dialog-store.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { createDisclosure } from "@/lib/disclosure";
|
||||
|
||||
export const shareDialog = createDisclosure<{ key: string; prefix: string }>();
|
@ -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
|
||||
/>
|
||||
<Button
|
||||
icon={Copy}
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
UseMutationOptions,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { Bucket, Permissions } from "../types";
|
||||
import { Bucket, Permissions, UpdateBucket } from "../types";
|
||||
|
||||
export const useBucket = (id?: string | null) => {
|
||||
return useQuery({
|
||||
@ -17,8 +17,8 @@ export const useBucket = (id?: string | null) => {
|
||||
|
||||
export const useUpdateBucket = (id?: string | null) => {
|
||||
return useMutation({
|
||||
mutationFn: (values: any) => {
|
||||
return api.post<any>("/v2/UpdateBucket", {
|
||||
mutationFn: (values: Partial<UpdateBucket>) => {
|
||||
return api.post<Bucket>("/v2/UpdateBucket", {
|
||||
params: { id },
|
||||
body: values,
|
||||
});
|
||||
@ -28,7 +28,7 @@ export const useUpdateBucket = (id?: string | null) => {
|
||||
|
||||
export const useAddAlias = (
|
||||
bucketId?: string | null,
|
||||
options?: UseMutationOptions<any, Error, string>
|
||||
options?: UseMutationOptions<unknown, Error, string>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (alias: string) => {
|
||||
@ -42,7 +42,7 @@ export const useAddAlias = (
|
||||
|
||||
export const useRemoveAlias = (
|
||||
bucketId?: string | null,
|
||||
options?: UseMutationOptions<any, Error, string>
|
||||
options?: UseMutationOptions<unknown, Error, string>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (alias: string) => {
|
||||
@ -57,7 +57,7 @@ export const useRemoveAlias = (
|
||||
export const useAllowKey = (
|
||||
bucketId?: string | null,
|
||||
options?: MutationOptions<
|
||||
any,
|
||||
unknown,
|
||||
Error,
|
||||
{ keyId: string; permissions: Permissions }[]
|
||||
>
|
||||
@ -83,7 +83,7 @@ export const useAllowKey = (
|
||||
export const useDenyKey = (
|
||||
bucketId?: string | null,
|
||||
options?: MutationOptions<
|
||||
any,
|
||||
unknown,
|
||||
Error,
|
||||
{ keyId: string; permissions: Permissions }
|
||||
>
|
||||
@ -103,7 +103,7 @@ export const useDenyKey = (
|
||||
};
|
||||
|
||||
export const useRemoveBucket = (
|
||||
options?: MutationOptions<any, Error, string>
|
||||
options?: MutationOptions<unknown, Error, string>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (id) => api.post("/v2/DeleteBucket", { params: { id } }),
|
||||
|
@ -60,7 +60,7 @@ const AddAliasDialog = ({ id }: { id?: string }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) form.setFocus("alias");
|
||||
}, [isOpen]);
|
||||
}, [isOpen, form]);
|
||||
|
||||
const addAlias = useAddAlias(id, {
|
||||
onSuccess: () => {
|
||||
|
@ -13,35 +13,48 @@ const QuotaSection = () => {
|
||||
|
||||
const form = useForm<QuotaSchema>({
|
||||
resolver: zodResolver(quotaSchema),
|
||||
defaultValues: {
|
||||
enabled: false,
|
||||
maxObjects: null,
|
||||
maxSize: null,
|
||||
},
|
||||
});
|
||||
const isEnabled = useWatch({ control: form.control, name: "enabled" });
|
||||
|
||||
const updateMutation = useUpdateBucket(data?.id);
|
||||
|
||||
const onChange = useDebounce((values: DeepPartial<QuotaSchema>) => {
|
||||
const handleChange = useDebounce((values: DeepPartial<QuotaSchema>) => {
|
||||
const { enabled } = values;
|
||||
const maxObjects = Number(values.maxObjects);
|
||||
const maxSize = Math.round(Number(values.maxSize) * 1024 ** 3);
|
||||
|
||||
const data = {
|
||||
const quotaData = {
|
||||
maxObjects: enabled && maxObjects > 0 ? maxObjects : null,
|
||||
maxSize: enabled && maxSize > 0 ? maxSize : null,
|
||||
};
|
||||
|
||||
updateMutation.mutate({ quotas: data });
|
||||
updateMutation.mutate({ quotas: quotaData });
|
||||
});
|
||||
|
||||
// Reset form when data changes without triggering watch
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
enabled:
|
||||
data?.quotas?.maxSize != null || data?.quotas?.maxObjects != null,
|
||||
maxSize: data?.quotas?.maxSize ? data?.quotas?.maxSize / 1024 ** 3 : null,
|
||||
maxObjects: data?.quotas?.maxObjects || null,
|
||||
});
|
||||
if (!data) return;
|
||||
|
||||
const { unsubscribe } = form.watch((values) => onChange(values));
|
||||
return unsubscribe;
|
||||
}, [data]);
|
||||
const formValues = {
|
||||
enabled:
|
||||
data.quotas?.maxSize != null || data.quotas?.maxObjects != null,
|
||||
maxSize: data.quotas?.maxSize ? data.quotas?.maxSize / 1024 ** 3 : null,
|
||||
maxObjects: data.quotas?.maxObjects || null,
|
||||
};
|
||||
|
||||
form.reset(formValues, { keepDirty: false });
|
||||
}, [data, form]);
|
||||
|
||||
// Set up form watcher
|
||||
useEffect(() => {
|
||||
const subscription = form.watch(handleChange);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, handleChange]);
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
|
@ -28,7 +28,7 @@ const OverviewTab = () => {
|
||||
<div className="flex-1">
|
||||
<p className="text-sm flex items-center gap-1">Storage</p>
|
||||
<p className="text-2xl font-medium">
|
||||
{readableBytes(data?.bytes)}
|
||||
{data?.bytes != null ? readableBytes(data.bytes) : "n/a"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -37,7 +37,9 @@ const OverviewTab = () => {
|
||||
<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>
|
||||
<p className="text-2xl font-medium">
|
||||
{data?.objects != null ? data.objects.toLocaleString() : "n/a"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -16,6 +16,11 @@ const WebsiteAccessSection = () => {
|
||||
const { data: config } = useConfig();
|
||||
const form = useForm<WebsiteConfigSchema>({
|
||||
resolver: zodResolver(websiteConfigSchema),
|
||||
defaultValues: {
|
||||
websiteAccess: false,
|
||||
indexDocument: "index.html",
|
||||
errorDocument: "error/400.html",
|
||||
},
|
||||
});
|
||||
const isEnabled = useWatch({ control: form.control, name: "websiteAccess" });
|
||||
|
||||
@ -24,34 +29,46 @@ const WebsiteAccessSection = () => {
|
||||
|
||||
const updateMutation = useUpdateBucket(data?.id);
|
||||
|
||||
const onChange = useDebounce((values: DeepPartial<WebsiteConfigSchema>) => {
|
||||
const data = {
|
||||
const handleChange = useDebounce((values: DeepPartial<WebsiteConfigSchema>) => {
|
||||
const websiteData = {
|
||||
enabled: values.websiteAccess,
|
||||
indexDocument: values.websiteAccess
|
||||
? values.websiteConfig?.indexDocument
|
||||
? values.indexDocument
|
||||
: undefined,
|
||||
errorDocument: values.websiteAccess
|
||||
? values.websiteConfig?.errorDocument
|
||||
? values.errorDocument
|
||||
: undefined,
|
||||
};
|
||||
|
||||
updateMutation.mutate({
|
||||
websiteAccess: data,
|
||||
websiteAccess: {
|
||||
enabled: values.websiteAccess ?? false,
|
||||
indexDocument: values.websiteAccess
|
||||
? websiteData.indexDocument ?? "index.html"
|
||||
: null,
|
||||
errorDocument: values.websiteAccess
|
||||
? websiteData.errorDocument ?? "error/400.html"
|
||||
: null,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reset form when data changes without triggering watch
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
form.reset({
|
||||
websiteAccess: data?.websiteAccess,
|
||||
websiteConfig: {
|
||||
websiteAccess: data?.websiteAccess ?? false,
|
||||
indexDocument: data?.websiteConfig?.indexDocument || "index.html",
|
||||
errorDocument: data?.websiteConfig?.errorDocument || "error/400.html",
|
||||
},
|
||||
});
|
||||
}, { keepDirty: false });
|
||||
}, [data, form]);
|
||||
|
||||
const { unsubscribe } = form.watch((values) => onChange(values));
|
||||
return unsubscribe;
|
||||
}, [data]);
|
||||
// Set up form watcher
|
||||
useEffect(() => {
|
||||
const subscription = form.watch(handleChange);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, handleChange]);
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
@ -75,12 +92,12 @@ const WebsiteAccessSection = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
form={form}
|
||||
name="websiteConfig.indexDocument"
|
||||
name="indexDocument"
|
||||
title="Index Document"
|
||||
/>
|
||||
<InputField
|
||||
form={form}
|
||||
name="websiteConfig.errorDocument"
|
||||
name="errorDocument"
|
||||
title="Error Document"
|
||||
/>
|
||||
</div>
|
||||
|
@ -54,7 +54,7 @@ const AllowKeyDialog = ({ currentKeys }: Props) => {
|
||||
}));
|
||||
|
||||
form.setValue("keys", _keys || []);
|
||||
}, [keys, currentKeys]);
|
||||
}, [keys, currentKeys, form]);
|
||||
|
||||
const onToggleAll = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
|
@ -59,7 +59,7 @@ const PermissionsTab = () => {
|
||||
|
||||
<Table.Body>
|
||||
{keys?.map((key, idx) => (
|
||||
<Table.Row>
|
||||
<Table.Row key={key.accessKeyId}>
|
||||
<span>{idx + 1}</span>
|
||||
<span>{key.name || key.accessKeyId?.substring(0, 8)}</span>
|
||||
<span>{key.bucketLocalAliases?.join(", ") || "-"}</span>
|
||||
@ -68,6 +68,7 @@ const PermissionsTab = () => {
|
||||
checked={key.permissions?.read}
|
||||
color="primary"
|
||||
className="cursor-default"
|
||||
readOnly
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
@ -75,6 +76,7 @@ const PermissionsTab = () => {
|
||||
checked={key.permissions?.write}
|
||||
color="primary"
|
||||
className="cursor-default"
|
||||
readOnly
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
@ -82,6 +84,7 @@ const PermissionsTab = () => {
|
||||
checked={key.permissions?.owner}
|
||||
color="primary"
|
||||
className="cursor-default"
|
||||
readOnly
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
|
@ -8,9 +8,8 @@ export type AddAliasSchema = z.infer<typeof addAliasSchema>;
|
||||
|
||||
export const websiteConfigSchema = z.object({
|
||||
websiteAccess: z.boolean(),
|
||||
websiteConfig: z
|
||||
.object({ indexDocument: z.string(), errorDocument: z.string() })
|
||||
.nullish(),
|
||||
indexDocument: z.string().nullish(),
|
||||
errorDocument: z.string().nullish(),
|
||||
});
|
||||
|
||||
export type WebsiteConfigSchema = z.infer<typeof websiteConfigSchema>;
|
||||
|
@ -7,7 +7,29 @@ export type Bucket = {
|
||||
globalAliases: string[];
|
||||
localAliases: LocalAlias[];
|
||||
websiteAccess: boolean;
|
||||
websiteConfig?: WebsiteConfig | null;
|
||||
websiteConfig: {
|
||||
indexDocument: string | null;
|
||||
errorDocument: string | null;
|
||||
};
|
||||
keys: Key[];
|
||||
objects: number;
|
||||
bytes: number;
|
||||
unfinishedUploads: number;
|
||||
unfinishedMultipartUploads: number;
|
||||
unfinishedMultipartUploadParts: number;
|
||||
unfinishedMultipartUploadBytes: number;
|
||||
quotas: Quotas;
|
||||
};
|
||||
|
||||
export type UpdateBucket = {
|
||||
id: string;
|
||||
globalAliases: string[];
|
||||
localAliases: LocalAlias[];
|
||||
websiteAccess: {
|
||||
enabled: boolean;
|
||||
indexDocument: string | null;
|
||||
errorDocument: string | null;
|
||||
};
|
||||
keys: Key[];
|
||||
objects: number;
|
||||
bytes: number;
|
||||
@ -36,12 +58,8 @@ export type Permissions = {
|
||||
owner: boolean;
|
||||
};
|
||||
|
||||
export type WebsiteConfig = {
|
||||
indexDocument: string;
|
||||
errorDocument: string;
|
||||
};
|
||||
|
||||
export type Quotas = {
|
||||
maxSize: null;
|
||||
maxObjects: null;
|
||||
maxSize: number | null;
|
||||
maxObjects: number | null;
|
||||
};
|
||||
|
@ -23,6 +23,8 @@ const defaultValues: AssignNodeSchema = {
|
||||
capacityUnit: "GB",
|
||||
isGateway: false,
|
||||
tags: [],
|
||||
zoneRedundancyType: "atLeast",
|
||||
zoneRedundancyAtLeast: 1,
|
||||
};
|
||||
|
||||
const AssignNodeDialog = () => {
|
||||
@ -36,6 +38,10 @@ const AssignNodeDialog = () => {
|
||||
defaultValues,
|
||||
});
|
||||
const isGateway = useWatch({ control: form.control, name: "isGateway" });
|
||||
const zoneRedundancyType = useWatch({
|
||||
control: form.control,
|
||||
name: "zoneRedundancyType",
|
||||
});
|
||||
|
||||
const assignNode = useAssignNode({
|
||||
onSuccess() {
|
||||
@ -63,7 +69,7 @@ const AssignNodeDialog = () => {
|
||||
isGateway,
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
}, [data, form]);
|
||||
|
||||
const zoneList = useMemo(() => {
|
||||
const nodes = cluster?.nodes || cluster?.knownNodes || [];
|
||||
@ -106,10 +112,20 @@ const AssignNodeDialog = () => {
|
||||
? calculateCapacity(values.capacity, values.capacityUnit)
|
||||
: null;
|
||||
const data = {
|
||||
parameters: {
|
||||
zoneRedundancy:
|
||||
values.zoneRedundancyType === "maximum"
|
||||
? ("maximum" as const)
|
||||
: { atLeast: Number(values.zoneRedundancyAtLeast) },
|
||||
},
|
||||
roles: [
|
||||
{
|
||||
id: values.nodeId,
|
||||
zone: values.zone,
|
||||
capacity,
|
||||
tags: values.tags,
|
||||
},
|
||||
],
|
||||
};
|
||||
assignNode.mutate(data);
|
||||
});
|
||||
@ -149,7 +165,10 @@ const AssignNodeDialog = () => {
|
||||
: null
|
||||
}
|
||||
options={zoneList}
|
||||
onChange={({ value }: any) => field.onChange(value)}
|
||||
onChange={(newValue) => {
|
||||
const value = newValue as { value: string } | null;
|
||||
field.onChange(value?.value || null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -162,7 +181,7 @@ const AssignNodeDialog = () => {
|
||||
name="isGateway"
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...(field as any)}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="mr-2"
|
||||
@ -178,13 +197,13 @@ const AssignNodeDialog = () => {
|
||||
<FormControl
|
||||
form={form}
|
||||
name="capacity"
|
||||
render={(field) => <Input type="number" {...(field as any)} />}
|
||||
render={(field) => <Input type="number" name={field.name} value={String(field.value || '')} onChange={field.onChange} />}
|
||||
/>
|
||||
<FormControl
|
||||
form={form}
|
||||
name="capacityUnit"
|
||||
render={(field) => (
|
||||
<Select {...(field as any)}>
|
||||
<Select name={field.name} value={String(field.value || '')} onChange={field.onChange}>
|
||||
<option value="">Select Unit</option>
|
||||
|
||||
{capacityUnits.map((unit) => (
|
||||
@ -225,6 +244,39 @@ const AssignNodeDialog = () => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
form={form}
|
||||
name="zoneRedundancyType"
|
||||
title="Zone Redundancy"
|
||||
render={(field) => (
|
||||
<Select
|
||||
name={field.name}
|
||||
value={String(field.value || "")}
|
||||
onChange={field.onChange}
|
||||
>
|
||||
<option value="atLeast">At Least</option>
|
||||
<option value="maximum">Maximum</option>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
{zoneRedundancyType === "atLeast" && (
|
||||
<FormControl
|
||||
form={form}
|
||||
name="zoneRedundancyAtLeast"
|
||||
title="Minimum Zones"
|
||||
className="mt-2"
|
||||
render={(field) => (
|
||||
<Input
|
||||
type="number"
|
||||
name={field.name}
|
||||
value={String(field.value || "")}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Actions>
|
||||
<Button type="button" onClick={assignNodeDialog.close}>
|
||||
|
@ -219,7 +219,7 @@ const NodesList = ({ nodes }: NodeListProps) => {
|
||||
<>
|
||||
<p>{item.role?.zone || "-"}</p>
|
||||
<div className="flex flex-row items-center flex-wrap gap-1">
|
||||
{item.role?.tags?.map((tag: any) => (
|
||||
{item.role?.tags?.map((tag: string) => (
|
||||
<Badge key={tag} color="primary">
|
||||
{tag}
|
||||
</Badge>
|
||||
|
@ -37,52 +37,49 @@ export const useClusterLayout = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useConnectNode = (options?: Partial<UseMutationOptions>) => {
|
||||
return useMutation<any, Error, string>({
|
||||
export interface ConnectNodeResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
// Add other fields if the API returns more data
|
||||
}
|
||||
|
||||
export const useConnectNode = (options?: Partial<UseMutationOptions<ConnectNodeResult, Error, string>>) => {
|
||||
return useMutation<ConnectNodeResult, Error, string>({
|
||||
mutationFn: async (nodeId) => {
|
||||
const [res] = await api.post("/v2/ConnectClusterNodes", {
|
||||
body: [nodeId],
|
||||
});
|
||||
if (!res.success) {
|
||||
throw new Error(res.error || "Unknown error");
|
||||
}
|
||||
const res = await api.post<ConnectNodeResult>("/v2/ConnectClusterNodes", { body: [nodeId] });
|
||||
return res;
|
||||
},
|
||||
...(options as any),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useAssignNode = (options?: Partial<UseMutationOptions>) => {
|
||||
return useMutation<any, Error, AssignNodeBody>({
|
||||
mutationFn: (data) =>
|
||||
api.post("/v2/UpdateClusterLayout", {
|
||||
body: { parameters: null, roles: [data] },
|
||||
}),
|
||||
...(options as any),
|
||||
export const useAssignNode = (options?: Partial<UseMutationOptions<void, Error, AssignNodeBody>>) => {
|
||||
return useMutation<void, Error, AssignNodeBody>({
|
||||
mutationFn: (data) => api.post("/v2/UpdateClusterLayout", { body: { parameters: data.parameters, roles: data.roles } }),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUnassignNode = (options?: Partial<UseMutationOptions>) => {
|
||||
return useMutation<any, Error, string>({
|
||||
export const useUnassignNode = (options?: Partial<UseMutationOptions<void, Error, string>>) => {
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (nodeId) =>
|
||||
api.post("/v2/UpdateClusterLayout", {
|
||||
body: { parameters: null, roles: [{ id: nodeId, remove: true }] },
|
||||
}),
|
||||
...(options as any),
|
||||
api.post("/v2/UpdateClusterLayout", { body: { parameters: null, roles: [{ id: nodeId, remove: true }] } }),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useRevertChanges = (options?: Partial<UseMutationOptions>) => {
|
||||
return useMutation<any, Error, number>({
|
||||
mutationFn: () => api.post("/v2/RevertClusterLayout"),
|
||||
...(options as any),
|
||||
export const useRevertChanges = (options?: Partial<UseMutationOptions<void, Error, number>>) => {
|
||||
return useMutation<void, Error, number>({
|
||||
mutationFn: (version) =>
|
||||
api.post("/v2/RevertClusterLayout", { body: { version } }),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useApplyChanges = (options?: Partial<UseMutationOptions>) => {
|
||||
export const useApplyChanges = (options?: Partial<UseMutationOptions<ApplyLayoutResult, Error, number>>) => {
|
||||
return useMutation<ApplyLayoutResult, Error, number>({
|
||||
mutationFn: (version) =>
|
||||
api.post("/v2/ApplyClusterLayout", { body: { version } }),
|
||||
...(options as any),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
@ -16,6 +16,8 @@ export const assignNodeSchema = z
|
||||
capacityUnit: z.enum(capacityUnits),
|
||||
isGateway: z.boolean(),
|
||||
tags: z.string().min(1).array(),
|
||||
zoneRedundancyType: z.enum(["atLeast", "maximum"]),
|
||||
zoneRedundancyAtLeast: z.coerce.number(),
|
||||
})
|
||||
.refine(
|
||||
(values) => values.isGateway || (values.capacity && values.capacity > 0),
|
||||
@ -23,6 +25,19 @@ export const assignNodeSchema = z
|
||||
message: "Capacity required",
|
||||
path: ["capacity"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.zoneRedundancyType === "atLeast" && !data.zoneRedundancyAtLeast) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Zone Redundancy At Least is required when Zone Redundancy Type is "atLeast"',
|
||||
path: ["zoneRedundancyAtLeast"],
|
||||
}
|
||||
);
|
||||
|
||||
export type AssignNodeSchema = z.infer<typeof assignNodeSchema>;
|
||||
|
@ -46,7 +46,7 @@ export type DataPartition = {
|
||||
export type Role = {
|
||||
id: string;
|
||||
zone: string;
|
||||
capacity: number;
|
||||
capacity: number | null;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
@ -60,11 +60,22 @@ export type GetClusterLayoutResult = {
|
||||
stagedRoleChanges: StagedRole[];
|
||||
};
|
||||
|
||||
export type AssignNodeBody = {
|
||||
export type PartitionNumber = {
|
||||
atLeast: number;
|
||||
};
|
||||
|
||||
export type LayoutParameters = {
|
||||
zoneRedundancy: "maximum" | PartitionNumber;
|
||||
}
|
||||
|
||||
export type NodeRoleChange = {
|
||||
remove: boolean;
|
||||
id: string;
|
||||
zone: string;
|
||||
capacity: number | null;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export type AssignNodeBody = {
|
||||
parameters: null | LayoutParameters,
|
||||
roles: Role[] | NodeRoleChange[];
|
||||
};
|
||||
|
||||
export type ApplyLayoutResult = {
|
||||
|
@ -13,16 +13,19 @@ import {
|
||||
PieChart,
|
||||
} from "lucide-react";
|
||||
import { cn, readableBytes, ucfirst } from "@/lib/utils";
|
||||
import { useBuckets } from "../buckets/hooks";
|
||||
import { useBucketsWithDetails } from "../buckets/hooks";
|
||||
import { useMemo } from "react";
|
||||
|
||||
const HomePage = () => {
|
||||
const { data: health } = useNodesHealth();
|
||||
const { data: buckets } = useBuckets();
|
||||
const bucketDetailsQueries = useBucketsWithDetails();
|
||||
|
||||
const totalUsage = useMemo(() => {
|
||||
return buckets?.reduce((acc, bucket) => acc + bucket.bytes, 0);
|
||||
}, [buckets]);
|
||||
return bucketDetailsQueries
|
||||
.map(query => query.data?.bytes)
|
||||
.filter(bytes => bytes != null)
|
||||
.reduce((acc, bytes) => acc + bytes, 0);
|
||||
}, [bucketDetailsQueries]);
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
|
@ -17,14 +17,19 @@ const CreateKeyDialog = () => {
|
||||
const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
|
||||
const form = useForm<CreateKeySchema>({
|
||||
resolver: zodResolver(createKeySchema),
|
||||
defaultValues: { name: "" },
|
||||
defaultValues: {
|
||||
name: "",
|
||||
isImport: false,
|
||||
accessKeyId: "",
|
||||
secretAccessKey: ""
|
||||
},
|
||||
});
|
||||
const isImport = useWatch({ control: form.control, name: "isImport" });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) form.setFocus("name");
|
||||
}, [isOpen]);
|
||||
}, [isOpen, form]);
|
||||
|
||||
const createKey = useCreateKey({
|
||||
onSuccess: () => {
|
||||
|
@ -15,7 +15,7 @@ export const useKeys = () => {
|
||||
};
|
||||
|
||||
export const useCreateKey = (
|
||||
options?: UseMutationOptions<any, Error, CreateKeySchema>
|
||||
options?: UseMutationOptions<unknown, Error, CreateKeySchema>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: async (body) => {
|
||||
@ -29,7 +29,7 @@ export const useCreateKey = (
|
||||
};
|
||||
|
||||
export const useRemoveKey = (
|
||||
options?: UseMutationOptions<any, Error, string>
|
||||
options?: UseMutationOptions<unknown, Error, string>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (id) => api.post("/v2/DeleteKey", { params: { id } }),
|
||||
|
@ -24,7 +24,7 @@ const KeysPage = () => {
|
||||
|
||||
const fetchSecretKey = useCallback(async (id: string) => {
|
||||
try {
|
||||
const result = await api.get("/v2/GetKeyInfo", {
|
||||
const result = await api.get<{ secretAccessKey: string }>("/v2/GetKeyInfo", {
|
||||
params: { id, showSecretKey: "true" },
|
||||
});
|
||||
if (!result?.secretAccessKey) {
|
||||
|
@ -6,9 +6,9 @@ export const createKeySchema = z
|
||||
.string()
|
||||
.min(1, "Key Name is required")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "Key Name invalid"),
|
||||
isImport: z.boolean().nullish(),
|
||||
accessKeyId: z.string().nullish(),
|
||||
secretAccessKey: z.string().nullish(),
|
||||
isImport: z.boolean().default(false),
|
||||
accessKeyId: z.string().optional(),
|
||||
secretAccessKey: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) => !v.isImport || (v.accessKeyId != null && v.accessKeyId.length > 0),
|
||||
|
@ -4,3 +4,7 @@ export type Key = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type KeyWithSecret = Key & {
|
||||
secretAccessKey: string;
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user