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*
|
||||||
!.env.example
|
!.env.example
|
||||||
docker-compose.*.yml
|
docker-compose.*.yml
|
||||||
|
!docker-compose.dev.yml
|
||||||
|
|
||||||
data/
|
data/
|
||||||
meta/
|
meta/
|
||||||
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
|||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
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)
|
[](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) ]
|
[ [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
|
### Docker CLI
|
||||||
|
|
||||||
```sh
|
```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
|
### Docker Compose
|
||||||
@ -45,7 +45,7 @@ services:
|
|||||||
- 3903:3903
|
- 3903:3903
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
image: khairul169/garage-webui:latest
|
image: ghcr.io/khairul169/garage-webui:latest
|
||||||
container_name: garage-webui
|
container_name: garage-webui
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
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:
|
Get the latest binary from the [release page](https://github.com/khairul169/garage-webui/releases/latest) according to your OS architecture. For example:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ wget -O garage-webui https://github.com/khairul169/garage-webui/releases/download/1.1.0/garage-webui-v1.1.0-linux-amd64
|
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
|
chmod +x garage-webui
|
||||||
$ sudo cp garage-webui /usr/local/bin
|
sudo cp garage-webui /usr/local/bin
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the program with specified `garage.toml` config path.
|
Run the program with specified `garage.toml` config path.
|
||||||
|
|
||||||
```sh
|
```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.
|
If you want to run the program at startup, you may want to create a systemd service.
|
||||||
|
|
||||||
```sh
|
```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.
|
Then reload and start the garage-webui service.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
$ sudo systemctl enable --now garage-webui
|
sudo systemctl enable --now garage-webui
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration
|
### 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.
|
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
|
```toml
|
||||||
metadata_dir = "/var/lib/garage/meta"
|
metadata_dir = "/var/lib/garage/meta"
|
||||||
data_dir = "/var/lib/garage/data"
|
data_dir = "/var/lib/garage/data"
|
||||||
db_engine = "sqlite"
|
db_engine = "sqlite"
|
||||||
metadata_auto_snapshot_interval = "6h"
|
|
||||||
|
|
||||||
replication_factor = 3
|
replication_factor = 3
|
||||||
compression_level = 2
|
compression_level = 2
|
||||||
|
|
||||||
rpc_bind_addr = "[::]:3901"
|
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"
|
rpc_secret = "YOUR_RPC_SECRET_HERE"
|
||||||
|
|
||||||
[s3_api]
|
[s3_api]
|
||||||
@ -130,7 +129,7 @@ bind_addr = "[::]:3902"
|
|||||||
root_domain = ".web.domain.com"
|
root_domain = ".web.domain.com"
|
||||||
index = "index.html"
|
index = "index.html"
|
||||||
|
|
||||||
[admin] # Required
|
[admin] # Required for Web UI
|
||||||
api_bind_addr = "[::]:3903"
|
api_bind_addr = "[::]:3903"
|
||||||
admin_token = "YOUR_ADMIN_TOKEN_HERE"
|
admin_token = "YOUR_ADMIN_TOKEN_HERE"
|
||||||
metrics_token = "YOUR_METRICS_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
|
### Setup
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ git clone https://github.com/khairul169/garage-webui.git
|
git clone https://github.com/khairul169/garage-webui.git
|
||||||
$ cd garage-webui && pnpm install
|
cd garage-webui && pnpm install
|
||||||
$ cd backend && pnpm install && cd ..
|
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
|
```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=""
|
BASE_PATH=""
|
||||||
AUTH_USER_PASS='username:$2y$10$DSTi9o0uQPEHSNlf66xMEOgm9KgVNBP3vHxA3SK0Xha2EVMb3mTXm'
|
AUTH_USER_PASS='admin:$2y$10$2i1DScIpTap7oB6KEYLP7um9/ms6LBf.TBzuqfSWRdRMvWRe35Y0S'
|
||||||
API_BASE_URL="http://garage:3903"
|
API_BASE_URL="http://localhost:3903"
|
||||||
S3_ENDPOINT_URL="http://garage:3900"
|
S3_ENDPOINT_URL="http://localhost:3900"
|
||||||
API_ADMIN_KEY=""
|
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
|
- 3903:3903
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
image: khairul169/garage-webui:latest
|
image: ghcr.io/khairul169/garage-webui:latest
|
||||||
container_name: garage-webui
|
container_name: garage-webui
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
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 { 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 { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import Sidebar from "../containers/sidebar";
|
import Sidebar from "../containers/sidebar";
|
||||||
import { ArrowLeft, MenuIcon } from "lucide-react";
|
import { ArrowLeft, MenuIcon } from "lucide-react";
|
||||||
@ -13,9 +13,12 @@ const MainLayout = () => {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
|
const sidebarRef = useRef(sidebar);
|
||||||
|
sidebarRef.current = sidebar;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sidebar.isOpen) {
|
if (sidebarRef.current.isOpen) {
|
||||||
sidebar.onClose();
|
sidebarRef.current.onClose();
|
||||||
}
|
}
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
@ -9,17 +9,42 @@ type Props = React.ComponentPropsWithoutRef<"div"> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Chips = forwardRef<HTMLDivElement, Props>(
|
const Chips = forwardRef<HTMLDivElement, Props>(
|
||||||
({ className, children, onRemove, ...props }, ref) => {
|
({ className, children, onRemove, onClick, ...props }, ref) => {
|
||||||
const Comp = props.onClick ? "button" : "div";
|
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 (
|
||||||
|
<button
|
||||||
|
{...commonProps}
|
||||||
|
onClick={onClick}
|
||||||
|
{...(props as React.ComponentPropsWithoutRef<"button">)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{onRemove ? (
|
||||||
|
<Button
|
||||||
|
color="ghost"
|
||||||
|
shape="circle"
|
||||||
|
size="sm"
|
||||||
|
className="-mr-3"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<div
|
||||||
ref={ref as never}
|
{...commonProps}
|
||||||
className={cn(
|
{...props}
|
||||||
"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)}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{onRemove ? (
|
{onRemove ? (
|
||||||
@ -33,7 +58,7 @@ const Chips = forwardRef<HTMLDivElement, Props>(
|
|||||||
<X size={16} />
|
<X size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</Comp>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -8,7 +8,7 @@ type Props = ComponentPropsWithoutRef<typeof BaseSelect> & {
|
|||||||
onCreateOption?: (inputValue: string) => void;
|
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;
|
const Comp = creatable ? Creatable : BaseSelect;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -5,6 +5,8 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
@ -16,8 +18,8 @@ type PageContextValues = {
|
|||||||
|
|
||||||
export const PageContext = createContext<
|
export const PageContext = createContext<
|
||||||
| (PageContextValues & {
|
| (PageContextValues & {
|
||||||
setValue: (values: Partial<PageContextValues>) => void;
|
setValue: (values: Partial<PageContextValues>) => void;
|
||||||
})
|
})
|
||||||
| null
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
@ -34,8 +36,13 @@ export const PageContextProvider = ({ children }: PropsWithChildren) => {
|
|||||||
setValues((prev) => ({ ...prev, ...value }));
|
setValues((prev) => ({ ...prev, ...value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const contextValue = useMemo(() => ({
|
||||||
|
...values,
|
||||||
|
setValue
|
||||||
|
}), [values, setValue]);
|
||||||
|
|
||||||
return (
|
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");
|
throw new Error("Page component must be used within a PageContextProvider");
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const setValueRef = useRef(context.setValue);
|
||||||
context.setValue(props);
|
setValueRef.current = context.setValue;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValueRef.current(props);
|
||||||
|
}, [props]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
context.setValue(initialValues);
|
setValueRef.current(initialValues);
|
||||||
};
|
};
|
||||||
}, [props, context.setValue]);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
|
|
||||||
export const useDebounce = <T extends (...args: any[]) => void>(
|
export const useDebounce = <Args extends unknown[]>(
|
||||||
fn: T,
|
fn: (...args: Args) => void,
|
||||||
delay: number = 500
|
delay: number = 500
|
||||||
) => {
|
) => {
|
||||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const debouncedFn = useCallback(
|
const debouncedFn = useCallback(
|
||||||
(...args: any[]) => {
|
(...args: Args) => {
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
}
|
}
|
||||||
timerRef.current = setTimeout(() => fn(...args), delay);
|
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";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export const useDisclosure = <T = any>() => {
|
export const useDisclosure = <T = unknown>() => {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [data, setData] = useState<T | null | undefined>(null);
|
const [data, setData] = useState<T | null | undefined>(null);
|
||||||
|
@ -2,9 +2,9 @@ import * as utils from "@/lib/utils";
|
|||||||
import { BASE_PATH } from "./consts";
|
import { BASE_PATH } from "./consts";
|
||||||
|
|
||||||
type FetchOptions = Omit<RequestInit, "headers" | "body"> & {
|
type FetchOptions = Omit<RequestInit, "headers" | "body"> & {
|
||||||
params?: Record<string, any>;
|
params?: Record<string, unknown>;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: any;
|
body?: BodyInit | Record<string, unknown> | unknown[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const API_URL = BASE_PATH + "/api";
|
export const API_URL = BASE_PATH + "/api";
|
||||||
@ -20,7 +20,7 @@ export class APIError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = {
|
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 headers: Record<string, string> = {};
|
||||||
const _url = new URL(API_URL + url, window.location.origin);
|
const _url = new URL(API_URL + url, window.location.origin);
|
||||||
|
|
||||||
@ -30,16 +30,27 @@ const api = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
let body: BodyInit | null | undefined = undefined;
|
||||||
typeof options?.body === "object" &&
|
if (options?.body) {
|
||||||
!(options.body instanceof FormData)
|
if (
|
||||||
) {
|
(typeof options.body === "object" && !Array.isArray(options.body) &&
|
||||||
options.body = JSON.stringify(options.body);
|
!(options.body instanceof FormData) &&
|
||||||
headers["Content-Type"] = "application/json";
|
!(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, {
|
const res = await fetch(_url, {
|
||||||
...options,
|
...options,
|
||||||
|
body,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { ...headers, ...(options?.headers || {}) },
|
headers: { ...headers, ...(options?.headers || {}) },
|
||||||
});
|
});
|
||||||
@ -66,28 +77,28 @@ const api = {
|
|||||||
return data as unknown as T;
|
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, {
|
return this.fetch<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
method: "GET",
|
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, {
|
return this.fetch<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
method: "POST",
|
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, {
|
return this.fetch<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
method: "PUT",
|
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, {
|
return this.fetch<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { createStore, useStore } from "zustand";
|
import { createStore, useStore } from "zustand";
|
||||||
|
|
||||||
export const createDisclosure = <T = any>() => {
|
export const createDisclosure = <T = unknown>() => {
|
||||||
const store = createStore(() => ({
|
const store = createStore(() => ({
|
||||||
data: undefined as T | null,
|
data: undefined as T | null,
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
|
@ -8,7 +8,7 @@ import { BASE_PATH } from "./consts";
|
|||||||
dayjs.extend(dayjsRelativeTime);
|
dayjs.extend(dayjsRelativeTime);
|
||||||
export { dayjs };
|
export { dayjs };
|
||||||
|
|
||||||
export const cn = (...args: any[]) => {
|
export const cn = (...args: Parameters<typeof clsx>) => {
|
||||||
return twMerge(clsx(...args));
|
return twMerge(clsx(...args));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2,12 +2,19 @@ import { Bucket } from "../types";
|
|||||||
import { ArchiveIcon, ChartPie, ChartScatter } from "lucide-react";
|
import { ArchiveIcon, ChartPie, ChartScatter } from "lucide-react";
|
||||||
import { readableBytes } from "@/lib/utils";
|
import { readableBytes } from "@/lib/utils";
|
||||||
import Button from "@/components/ui/button";
|
import Button from "@/components/ui/button";
|
||||||
|
import { useBucketDetails } from "../hooks";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: Bucket & { aliases: string[] };
|
data: Bucket & { aliases: string[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
const BucketCard = ({ data }: Props) => {
|
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 (
|
return (
|
||||||
<div className="card card-body p-6">
|
<div className="card card-body p-6">
|
||||||
<div className="grid grid-cols-2 items-start gap-4 p-2 pb-0">
|
<div className="grid grid-cols-2 items-start gap-4 p-2 pb-0">
|
||||||
@ -25,7 +32,7 @@ const BucketCard = ({ data }: Props) => {
|
|||||||
Usage
|
Usage
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xl font-medium mt-1">
|
<p className="text-xl font-medium mt-1">
|
||||||
{readableBytes(data.bytes)}
|
{bytes != null ? readableBytes(bytes) : "n/a"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -34,7 +41,9 @@ const BucketCard = ({ data }: Props) => {
|
|||||||
<ChartScatter className="inline" size={16} />
|
<ChartScatter className="inline" size={16} />
|
||||||
Objects
|
Objects
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ const CreateBucketDialog = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) form.setFocus("globalAlias");
|
if (isOpen) form.setFocus("globalAlias");
|
||||||
}, [isOpen]);
|
}, [isOpen, form]);
|
||||||
|
|
||||||
const createBucket = useCreateBucket({
|
const createBucket = useCreateBucket({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
@ -3,19 +3,40 @@ import {
|
|||||||
useMutation,
|
useMutation,
|
||||||
UseMutationOptions,
|
UseMutationOptions,
|
||||||
useQuery,
|
useQuery,
|
||||||
|
useQueries,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { GetBucketRes } from "./types";
|
import { GetBucketRes, Bucket } from "./types";
|
||||||
import { CreateBucketSchema } from "./schema";
|
import { CreateBucketSchema } from "./schema";
|
||||||
|
|
||||||
export const useBuckets = () => {
|
export const useBuckets = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["buckets"],
|
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 = (
|
export const useCreateBucket = (
|
||||||
options?: UseMutationOptions<any, Error, CreateBucketSchema>
|
options?: UseMutationOptions<unknown, Error, CreateBucketSchema>
|
||||||
) => {
|
) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body) => api.post("/v2/CreateBucket", { body }),
|
mutationFn: (body) => api.post("/v2/CreateBucket", { body }),
|
||||||
|
@ -86,7 +86,7 @@ const CreateFolderAction = ({ prefix }: CreateFolderActionProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) form.setFocus("name");
|
if (isOpen) form.setFocus("name");
|
||||||
}, [isOpen]);
|
}, [isOpen, form]);
|
||||||
|
|
||||||
const createFolder = usePutObject(bucketName, {
|
const createFolder = usePutObject(bucketName, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
@ -30,7 +30,7 @@ const BrowseTab = () => {
|
|||||||
const newParams = new URLSearchParams(searchParams);
|
const newParams = new URLSearchParams(searchParams);
|
||||||
newParams.set("prefix", prefix);
|
newParams.set("prefix", prefix);
|
||||||
setSearchParams(newParams);
|
setSearchParams(newParams);
|
||||||
}, [curPrefix]);
|
}, [curPrefix, prefixHistory, searchParams, setSearchParams]);
|
||||||
|
|
||||||
const gotoPrefix = (prefix: string) => {
|
const gotoPrefix = (prefix: string) => {
|
||||||
const history = prefixHistory.slice(0, curPrefix + 1);
|
const history = prefixHistory.slice(0, curPrefix + 1);
|
||||||
|
@ -23,7 +23,7 @@ export const useBrowseObjects = (
|
|||||||
|
|
||||||
export const usePutObject = (
|
export const usePutObject = (
|
||||||
bucket: string,
|
bucket: string,
|
||||||
options?: UseMutationOptions<any, Error, PutObjectPayload>
|
options?: UseMutationOptions<unknown, Error, PutObjectPayload>
|
||||||
) => {
|
) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (body) => {
|
mutationFn: async (body) => {
|
||||||
@ -40,7 +40,7 @@ export const usePutObject = (
|
|||||||
|
|
||||||
export const useDeleteObject = (
|
export const useDeleteObject = (
|
||||||
bucket: string,
|
bucket: string,
|
||||||
options?: UseMutationOptions<any, Error, { key: string; recursive?: boolean }>
|
options?: UseMutationOptions<unknown, Error, { key: string; recursive?: boolean }>
|
||||||
) => {
|
) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data) =>
|
mutationFn: (data) =>
|
||||||
|
@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { handleError } from "@/lib/utils";
|
import { handleError } from "@/lib/utils";
|
||||||
import { API_URL } from "@/lib/api";
|
import { API_URL } from "@/lib/api";
|
||||||
import { shareDialog } from "./share-dialog";
|
import { shareDialog } from "./share-dialog-store";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
@ -36,8 +36,7 @@ const ObjectActions = ({ prefix = "", object, end }: Props) => {
|
|||||||
const onDelete = () => {
|
const onDelete = () => {
|
||||||
if (
|
if (
|
||||||
window.confirm(
|
window.confirm(
|
||||||
`Are you sure you want to delete this ${
|
`Are you sure you want to delete this ${isDirectory ? "directory and its content" : "object"
|
||||||
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 { Alert, Modal } from "react-daisyui";
|
||||||
import { useBucketContext } from "../context";
|
import { useBucketContext } from "../context";
|
||||||
import { useConfig } from "@/hooks/useConfig";
|
import { useConfig } from "@/hooks/useConfig";
|
||||||
@ -8,8 +7,7 @@ import Button from "@/components/ui/button";
|
|||||||
import { Copy, FileWarningIcon } from "lucide-react";
|
import { Copy, FileWarningIcon } from "lucide-react";
|
||||||
import { copyToClipboard } from "@/lib/utils";
|
import { copyToClipboard } from "@/lib/utils";
|
||||||
import Checkbox from "@/components/ui/checkbox";
|
import Checkbox from "@/components/ui/checkbox";
|
||||||
|
import { shareDialog } from "./share-dialog-store";
|
||||||
export const shareDialog = createDisclosure<{ key: string; prefix: string }>();
|
|
||||||
|
|
||||||
const ShareDialog = () => {
|
const ShareDialog = () => {
|
||||||
const { isOpen, data, dialogRef } = shareDialog.use();
|
const { isOpen, data, dialogRef } = shareDialog.use();
|
||||||
@ -26,12 +24,12 @@ const ShareDialog = () => {
|
|||||||
bucketName + rootDomain,
|
bucketName + rootDomain,
|
||||||
bucketName + rootDomain + `:${websitePort}`,
|
bucketName + rootDomain + `:${websitePort}`,
|
||||||
],
|
],
|
||||||
[bucketName, config?.s3_web]
|
[bucketName, rootDomain, websitePort]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDomain(bucketName);
|
setDomain(bucketName);
|
||||||
}, [domains]);
|
}, [bucketName]);
|
||||||
|
|
||||||
const url = "http://" + domain + "/" + data?.prefix + data?.key;
|
const url = "http://" + domain + "/" + data?.prefix + data?.key;
|
||||||
|
|
||||||
@ -60,6 +58,7 @@ const ShareDialog = () => {
|
|||||||
value={url}
|
value={url}
|
||||||
className="w-full pr-12"
|
className="w-full pr-12"
|
||||||
onFocus={(e) => e.target.select()}
|
onFocus={(e) => e.target.select()}
|
||||||
|
readOnly
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={Copy}
|
icon={Copy}
|
||||||
|
@ -5,7 +5,7 @@ import {
|
|||||||
UseMutationOptions,
|
UseMutationOptions,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { Bucket, Permissions } from "../types";
|
import { Bucket, Permissions, UpdateBucket } from "../types";
|
||||||
|
|
||||||
export const useBucket = (id?: string | null) => {
|
export const useBucket = (id?: string | null) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@ -17,8 +17,8 @@ export const useBucket = (id?: string | null) => {
|
|||||||
|
|
||||||
export const useUpdateBucket = (id?: string | null) => {
|
export const useUpdateBucket = (id?: string | null) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (values: any) => {
|
mutationFn: (values: Partial<UpdateBucket>) => {
|
||||||
return api.post<any>("/v2/UpdateBucket", {
|
return api.post<Bucket>("/v2/UpdateBucket", {
|
||||||
params: { id },
|
params: { id },
|
||||||
body: values,
|
body: values,
|
||||||
});
|
});
|
||||||
@ -28,7 +28,7 @@ export const useUpdateBucket = (id?: string | null) => {
|
|||||||
|
|
||||||
export const useAddAlias = (
|
export const useAddAlias = (
|
||||||
bucketId?: string | null,
|
bucketId?: string | null,
|
||||||
options?: UseMutationOptions<any, Error, string>
|
options?: UseMutationOptions<unknown, Error, string>
|
||||||
) => {
|
) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (alias: string) => {
|
mutationFn: (alias: string) => {
|
||||||
@ -42,7 +42,7 @@ export const useAddAlias = (
|
|||||||
|
|
||||||
export const useRemoveAlias = (
|
export const useRemoveAlias = (
|
||||||
bucketId?: string | null,
|
bucketId?: string | null,
|
||||||
options?: UseMutationOptions<any, Error, string>
|
options?: UseMutationOptions<unknown, Error, string>
|
||||||
) => {
|
) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (alias: string) => {
|
mutationFn: (alias: string) => {
|
||||||
@ -57,7 +57,7 @@ export const useRemoveAlias = (
|
|||||||
export const useAllowKey = (
|
export const useAllowKey = (
|
||||||
bucketId?: string | null,
|
bucketId?: string | null,
|
||||||
options?: MutationOptions<
|
options?: MutationOptions<
|
||||||
any,
|
unknown,
|
||||||
Error,
|
Error,
|
||||||
{ keyId: string; permissions: Permissions }[]
|
{ keyId: string; permissions: Permissions }[]
|
||||||
>
|
>
|
||||||
@ -83,7 +83,7 @@ export const useAllowKey = (
|
|||||||
export const useDenyKey = (
|
export const useDenyKey = (
|
||||||
bucketId?: string | null,
|
bucketId?: string | null,
|
||||||
options?: MutationOptions<
|
options?: MutationOptions<
|
||||||
any,
|
unknown,
|
||||||
Error,
|
Error,
|
||||||
{ keyId: string; permissions: Permissions }
|
{ keyId: string; permissions: Permissions }
|
||||||
>
|
>
|
||||||
@ -103,7 +103,7 @@ export const useDenyKey = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useRemoveBucket = (
|
export const useRemoveBucket = (
|
||||||
options?: MutationOptions<any, Error, string>
|
options?: MutationOptions<unknown, Error, string>
|
||||||
) => {
|
) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id) => api.post("/v2/DeleteBucket", { params: { id } }),
|
mutationFn: (id) => api.post("/v2/DeleteBucket", { params: { id } }),
|
||||||
|
@ -60,7 +60,7 @@ const AddAliasDialog = ({ id }: { id?: string }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) form.setFocus("alias");
|
if (isOpen) form.setFocus("alias");
|
||||||
}, [isOpen]);
|
}, [isOpen, form]);
|
||||||
|
|
||||||
const addAlias = useAddAlias(id, {
|
const addAlias = useAddAlias(id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
@ -13,35 +13,48 @@ const QuotaSection = () => {
|
|||||||
|
|
||||||
const form = useForm<QuotaSchema>({
|
const form = useForm<QuotaSchema>({
|
||||||
resolver: zodResolver(quotaSchema),
|
resolver: zodResolver(quotaSchema),
|
||||||
|
defaultValues: {
|
||||||
|
enabled: false,
|
||||||
|
maxObjects: null,
|
||||||
|
maxSize: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const isEnabled = useWatch({ control: form.control, name: "enabled" });
|
const isEnabled = useWatch({ control: form.control, name: "enabled" });
|
||||||
|
|
||||||
const updateMutation = useUpdateBucket(data?.id);
|
const updateMutation = useUpdateBucket(data?.id);
|
||||||
|
|
||||||
const onChange = useDebounce((values: DeepPartial<QuotaSchema>) => {
|
const handleChange = useDebounce((values: DeepPartial<QuotaSchema>) => {
|
||||||
const { enabled } = values;
|
const { enabled } = values;
|
||||||
const maxObjects = Number(values.maxObjects);
|
const maxObjects = Number(values.maxObjects);
|
||||||
const maxSize = Math.round(Number(values.maxSize) * 1024 ** 3);
|
const maxSize = Math.round(Number(values.maxSize) * 1024 ** 3);
|
||||||
|
|
||||||
const data = {
|
const quotaData = {
|
||||||
maxObjects: enabled && maxObjects > 0 ? maxObjects : null,
|
maxObjects: enabled && maxObjects > 0 ? maxObjects : null,
|
||||||
maxSize: enabled && maxSize > 0 ? maxSize : null,
|
maxSize: enabled && maxSize > 0 ? maxSize : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
updateMutation.mutate({ quotas: data });
|
updateMutation.mutate({ quotas: quotaData });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset form when data changes without triggering watch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
if (!data) return;
|
||||||
enabled:
|
|
||||||
data?.quotas?.maxSize != null || data?.quotas?.maxObjects != null,
|
|
||||||
maxSize: data?.quotas?.maxSize ? data?.quotas?.maxSize / 1024 ** 3 : null,
|
|
||||||
maxObjects: data?.quotas?.maxObjects || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { unsubscribe } = form.watch((values) => onChange(values));
|
const formValues = {
|
||||||
return unsubscribe;
|
enabled:
|
||||||
}, [data]);
|
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 (
|
return (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
|
@ -28,7 +28,7 @@ const OverviewTab = () => {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm flex items-center gap-1">Storage</p>
|
<p className="text-sm flex items-center gap-1">Storage</p>
|
||||||
<p className="text-2xl font-medium">
|
<p className="text-2xl font-medium">
|
||||||
{readableBytes(data?.bytes)}
|
{data?.bytes != null ? readableBytes(data.bytes) : "n/a"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -37,7 +37,9 @@ const OverviewTab = () => {
|
|||||||
<ChartScatter className="mt-1" size={20} />
|
<ChartScatter className="mt-1" size={20} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm flex items-center gap-1">Objects</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -16,6 +16,11 @@ const WebsiteAccessSection = () => {
|
|||||||
const { data: config } = useConfig();
|
const { data: config } = useConfig();
|
||||||
const form = useForm<WebsiteConfigSchema>({
|
const form = useForm<WebsiteConfigSchema>({
|
||||||
resolver: zodResolver(websiteConfigSchema),
|
resolver: zodResolver(websiteConfigSchema),
|
||||||
|
defaultValues: {
|
||||||
|
websiteAccess: false,
|
||||||
|
indexDocument: "index.html",
|
||||||
|
errorDocument: "error/400.html",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const isEnabled = useWatch({ control: form.control, name: "websiteAccess" });
|
const isEnabled = useWatch({ control: form.control, name: "websiteAccess" });
|
||||||
|
|
||||||
@ -24,34 +29,46 @@ const WebsiteAccessSection = () => {
|
|||||||
|
|
||||||
const updateMutation = useUpdateBucket(data?.id);
|
const updateMutation = useUpdateBucket(data?.id);
|
||||||
|
|
||||||
const onChange = useDebounce((values: DeepPartial<WebsiteConfigSchema>) => {
|
const handleChange = useDebounce((values: DeepPartial<WebsiteConfigSchema>) => {
|
||||||
const data = {
|
const websiteData = {
|
||||||
enabled: values.websiteAccess,
|
enabled: values.websiteAccess,
|
||||||
indexDocument: values.websiteAccess
|
indexDocument: values.websiteAccess
|
||||||
? values.websiteConfig?.indexDocument
|
? values.indexDocument
|
||||||
: undefined,
|
: undefined,
|
||||||
errorDocument: values.websiteAccess
|
errorDocument: values.websiteAccess
|
||||||
? values.websiteConfig?.errorDocument
|
? values.errorDocument
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
updateMutation.mutate({
|
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(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
if (!data) return;
|
||||||
websiteAccess: data?.websiteAccess,
|
|
||||||
websiteConfig: {
|
|
||||||
indexDocument: data?.websiteConfig?.indexDocument || "index.html",
|
|
||||||
errorDocument: data?.websiteConfig?.errorDocument || "error/400.html",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { unsubscribe } = form.watch((values) => onChange(values));
|
form.reset({
|
||||||
return unsubscribe;
|
websiteAccess: data?.websiteAccess ?? false,
|
||||||
}, [data]);
|
indexDocument: data?.websiteConfig?.indexDocument || "index.html",
|
||||||
|
errorDocument: data?.websiteConfig?.errorDocument || "error/400.html",
|
||||||
|
}, { keepDirty: false });
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
// Set up form watcher
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = form.watch(handleChange);
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [form, handleChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
@ -75,12 +92,12 @@ const WebsiteAccessSection = () => {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<InputField
|
<InputField
|
||||||
form={form}
|
form={form}
|
||||||
name="websiteConfig.indexDocument"
|
name="indexDocument"
|
||||||
title="Index Document"
|
title="Index Document"
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
form={form}
|
form={form}
|
||||||
name="websiteConfig.errorDocument"
|
name="errorDocument"
|
||||||
title="Error Document"
|
title="Error Document"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -54,7 +54,7 @@ const AllowKeyDialog = ({ currentKeys }: Props) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
form.setValue("keys", _keys || []);
|
form.setValue("keys", _keys || []);
|
||||||
}, [keys, currentKeys]);
|
}, [keys, currentKeys, form]);
|
||||||
|
|
||||||
const onToggleAll = (
|
const onToggleAll = (
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
@ -59,7 +59,7 @@ const PermissionsTab = () => {
|
|||||||
|
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{keys?.map((key, idx) => (
|
{keys?.map((key, idx) => (
|
||||||
<Table.Row>
|
<Table.Row key={key.accessKeyId}>
|
||||||
<span>{idx + 1}</span>
|
<span>{idx + 1}</span>
|
||||||
<span>{key.name || key.accessKeyId?.substring(0, 8)}</span>
|
<span>{key.name || key.accessKeyId?.substring(0, 8)}</span>
|
||||||
<span>{key.bucketLocalAliases?.join(", ") || "-"}</span>
|
<span>{key.bucketLocalAliases?.join(", ") || "-"}</span>
|
||||||
@ -68,6 +68,7 @@ const PermissionsTab = () => {
|
|||||||
checked={key.permissions?.read}
|
checked={key.permissions?.read}
|
||||||
color="primary"
|
color="primary"
|
||||||
className="cursor-default"
|
className="cursor-default"
|
||||||
|
readOnly
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
@ -75,6 +76,7 @@ const PermissionsTab = () => {
|
|||||||
checked={key.permissions?.write}
|
checked={key.permissions?.write}
|
||||||
color="primary"
|
color="primary"
|
||||||
className="cursor-default"
|
className="cursor-default"
|
||||||
|
readOnly
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
@ -82,6 +84,7 @@ const PermissionsTab = () => {
|
|||||||
checked={key.permissions?.owner}
|
checked={key.permissions?.owner}
|
||||||
color="primary"
|
color="primary"
|
||||||
className="cursor-default"
|
className="cursor-default"
|
||||||
|
readOnly
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
|
@ -8,9 +8,8 @@ export type AddAliasSchema = z.infer<typeof addAliasSchema>;
|
|||||||
|
|
||||||
export const websiteConfigSchema = z.object({
|
export const websiteConfigSchema = z.object({
|
||||||
websiteAccess: z.boolean(),
|
websiteAccess: z.boolean(),
|
||||||
websiteConfig: z
|
indexDocument: z.string().nullish(),
|
||||||
.object({ indexDocument: z.string(), errorDocument: z.string() })
|
errorDocument: z.string().nullish(),
|
||||||
.nullish(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type WebsiteConfigSchema = z.infer<typeof websiteConfigSchema>;
|
export type WebsiteConfigSchema = z.infer<typeof websiteConfigSchema>;
|
||||||
|
@ -7,7 +7,29 @@ export type Bucket = {
|
|||||||
globalAliases: string[];
|
globalAliases: string[];
|
||||||
localAliases: LocalAlias[];
|
localAliases: LocalAlias[];
|
||||||
websiteAccess: boolean;
|
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[];
|
keys: Key[];
|
||||||
objects: number;
|
objects: number;
|
||||||
bytes: number;
|
bytes: number;
|
||||||
@ -36,12 +58,8 @@ export type Permissions = {
|
|||||||
owner: boolean;
|
owner: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WebsiteConfig = {
|
|
||||||
indexDocument: string;
|
|
||||||
errorDocument: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Quotas = {
|
export type Quotas = {
|
||||||
maxSize: null;
|
maxSize: number | null;
|
||||||
maxObjects: null;
|
maxObjects: number | null;
|
||||||
};
|
};
|
||||||
|
@ -23,6 +23,8 @@ const defaultValues: AssignNodeSchema = {
|
|||||||
capacityUnit: "GB",
|
capacityUnit: "GB",
|
||||||
isGateway: false,
|
isGateway: false,
|
||||||
tags: [],
|
tags: [],
|
||||||
|
zoneRedundancyType: "atLeast",
|
||||||
|
zoneRedundancyAtLeast: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssignNodeDialog = () => {
|
const AssignNodeDialog = () => {
|
||||||
@ -36,6 +38,10 @@ const AssignNodeDialog = () => {
|
|||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
const isGateway = useWatch({ control: form.control, name: "isGateway" });
|
const isGateway = useWatch({ control: form.control, name: "isGateway" });
|
||||||
|
const zoneRedundancyType = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: "zoneRedundancyType",
|
||||||
|
});
|
||||||
|
|
||||||
const assignNode = useAssignNode({
|
const assignNode = useAssignNode({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
@ -63,7 +69,7 @@ const AssignNodeDialog = () => {
|
|||||||
isGateway,
|
isGateway,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data, form]);
|
||||||
|
|
||||||
const zoneList = useMemo(() => {
|
const zoneList = useMemo(() => {
|
||||||
const nodes = cluster?.nodes || cluster?.knownNodes || [];
|
const nodes = cluster?.nodes || cluster?.knownNodes || [];
|
||||||
@ -106,10 +112,20 @@ const AssignNodeDialog = () => {
|
|||||||
? calculateCapacity(values.capacity, values.capacityUnit)
|
? calculateCapacity(values.capacity, values.capacityUnit)
|
||||||
: null;
|
: null;
|
||||||
const data = {
|
const data = {
|
||||||
id: values.nodeId,
|
parameters: {
|
||||||
zone: values.zone,
|
zoneRedundancy:
|
||||||
capacity,
|
values.zoneRedundancyType === "maximum"
|
||||||
tags: values.tags,
|
? ("maximum" as const)
|
||||||
|
: { atLeast: Number(values.zoneRedundancyAtLeast) },
|
||||||
|
},
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
id: values.nodeId,
|
||||||
|
zone: values.zone,
|
||||||
|
capacity,
|
||||||
|
tags: values.tags,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
assignNode.mutate(data);
|
assignNode.mutate(data);
|
||||||
});
|
});
|
||||||
@ -149,7 +165,10 @@ const AssignNodeDialog = () => {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
options={zoneList}
|
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"
|
name="isGateway"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
{...(field as any)}
|
name={field.name}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
@ -178,13 +197,13 @@ const AssignNodeDialog = () => {
|
|||||||
<FormControl
|
<FormControl
|
||||||
form={form}
|
form={form}
|
||||||
name="capacity"
|
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
|
<FormControl
|
||||||
form={form}
|
form={form}
|
||||||
name="capacityUnit"
|
name="capacityUnit"
|
||||||
render={(field) => (
|
render={(field) => (
|
||||||
<Select {...(field as any)}>
|
<Select name={field.name} value={String(field.value || '')} onChange={field.onChange}>
|
||||||
<option value="">Select Unit</option>
|
<option value="">Select Unit</option>
|
||||||
|
|
||||||
{capacityUnits.map((unit) => (
|
{capacityUnits.map((unit) => (
|
||||||
@ -211,9 +230,9 @@ const AssignNodeDialog = () => {
|
|||||||
value={
|
value={
|
||||||
field.value
|
field.value
|
||||||
? (field.value as string[]).map((value) => ({
|
? (field.value as string[]).map((value) => ({
|
||||||
label: value,
|
label: value,
|
||||||
value,
|
value,
|
||||||
}))
|
}))
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
options={tagsList}
|
options={tagsList}
|
||||||
@ -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.Body>
|
||||||
<Modal.Actions>
|
<Modal.Actions>
|
||||||
<Button type="button" onClick={assignNodeDialog.close}>
|
<Button type="button" onClick={assignNodeDialog.close}>
|
||||||
|
@ -219,7 +219,7 @@ const NodesList = ({ nodes }: NodeListProps) => {
|
|||||||
<>
|
<>
|
||||||
<p>{item.role?.zone || "-"}</p>
|
<p>{item.role?.zone || "-"}</p>
|
||||||
<div className="flex flex-row items-center flex-wrap gap-1">
|
<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">
|
<Badge key={tag} color="primary">
|
||||||
{tag}
|
{tag}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
@ -37,52 +37,49 @@ export const useClusterLayout = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useConnectNode = (options?: Partial<UseMutationOptions>) => {
|
export interface ConnectNodeResult {
|
||||||
return useMutation<any, Error, string>({
|
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) => {
|
mutationFn: async (nodeId) => {
|
||||||
const [res] = await api.post("/v2/ConnectClusterNodes", {
|
const res = await api.post<ConnectNodeResult>("/v2/ConnectClusterNodes", { body: [nodeId] });
|
||||||
body: [nodeId],
|
|
||||||
});
|
|
||||||
if (!res.success) {
|
|
||||||
throw new Error(res.error || "Unknown error");
|
|
||||||
}
|
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
...(options as any),
|
...options,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAssignNode = (options?: Partial<UseMutationOptions>) => {
|
export const useAssignNode = (options?: Partial<UseMutationOptions<void, Error, AssignNodeBody>>) => {
|
||||||
return useMutation<any, Error, AssignNodeBody>({
|
return useMutation<void, Error, AssignNodeBody>({
|
||||||
mutationFn: (data) =>
|
mutationFn: (data) => api.post("/v2/UpdateClusterLayout", { body: { parameters: data.parameters, roles: data.roles } }),
|
||||||
api.post("/v2/UpdateClusterLayout", {
|
...options,
|
||||||
body: { parameters: null, roles: [data] },
|
|
||||||
}),
|
|
||||||
...(options as any),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useUnassignNode = (options?: Partial<UseMutationOptions>) => {
|
export const useUnassignNode = (options?: Partial<UseMutationOptions<void, Error, string>>) => {
|
||||||
return useMutation<any, Error, string>({
|
return useMutation<void, Error, string>({
|
||||||
mutationFn: (nodeId) =>
|
mutationFn: (nodeId) =>
|
||||||
api.post("/v2/UpdateClusterLayout", {
|
api.post("/v2/UpdateClusterLayout", { body: { parameters: null, roles: [{ id: nodeId, remove: true }] } }),
|
||||||
body: { parameters: null, roles: [{ id: nodeId, remove: true }] },
|
...options,
|
||||||
}),
|
|
||||||
...(options as any),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRevertChanges = (options?: Partial<UseMutationOptions>) => {
|
export const useRevertChanges = (options?: Partial<UseMutationOptions<void, Error, number>>) => {
|
||||||
return useMutation<any, Error, number>({
|
return useMutation<void, Error, number>({
|
||||||
mutationFn: () => api.post("/v2/RevertClusterLayout"),
|
mutationFn: (version) =>
|
||||||
...(options as any),
|
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>({
|
return useMutation<ApplyLayoutResult, Error, number>({
|
||||||
mutationFn: (version) =>
|
mutationFn: (version) =>
|
||||||
api.post("/v2/ApplyClusterLayout", { body: { version } }),
|
api.post("/v2/ApplyClusterLayout", { body: { version } }),
|
||||||
...(options as any),
|
...options,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -16,6 +16,8 @@ export const assignNodeSchema = z
|
|||||||
capacityUnit: z.enum(capacityUnits),
|
capacityUnit: z.enum(capacityUnits),
|
||||||
isGateway: z.boolean(),
|
isGateway: z.boolean(),
|
||||||
tags: z.string().min(1).array(),
|
tags: z.string().min(1).array(),
|
||||||
|
zoneRedundancyType: z.enum(["atLeast", "maximum"]),
|
||||||
|
zoneRedundancyAtLeast: z.coerce.number(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(values) => values.isGateway || (values.capacity && values.capacity > 0),
|
(values) => values.isGateway || (values.capacity && values.capacity > 0),
|
||||||
@ -23,6 +25,19 @@ export const assignNodeSchema = z
|
|||||||
message: "Capacity required",
|
message: "Capacity required",
|
||||||
path: ["capacity"],
|
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>;
|
export type AssignNodeSchema = z.infer<typeof assignNodeSchema>;
|
||||||
|
@ -46,7 +46,7 @@ export type DataPartition = {
|
|||||||
export type Role = {
|
export type Role = {
|
||||||
id: string;
|
id: string;
|
||||||
zone: string;
|
zone: string;
|
||||||
capacity: number;
|
capacity: number | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -60,11 +60,22 @@ export type GetClusterLayoutResult = {
|
|||||||
stagedRoleChanges: StagedRole[];
|
stagedRoleChanges: StagedRole[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AssignNodeBody = {
|
export type PartitionNumber = {
|
||||||
|
atLeast: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LayoutParameters = {
|
||||||
|
zoneRedundancy: "maximum" | PartitionNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeRoleChange = {
|
||||||
|
remove: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
zone: string;
|
}
|
||||||
capacity: number | null;
|
|
||||||
tags: string[];
|
export type AssignNodeBody = {
|
||||||
|
parameters: null | LayoutParameters,
|
||||||
|
roles: Role[] | NodeRoleChange[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApplyLayoutResult = {
|
export type ApplyLayoutResult = {
|
||||||
|
@ -13,16 +13,19 @@ import {
|
|||||||
PieChart,
|
PieChart,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, readableBytes, ucfirst } from "@/lib/utils";
|
import { cn, readableBytes, ucfirst } from "@/lib/utils";
|
||||||
import { useBuckets } from "../buckets/hooks";
|
import { useBucketsWithDetails } from "../buckets/hooks";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const { data: health } = useNodesHealth();
|
const { data: health } = useNodesHealth();
|
||||||
const { data: buckets } = useBuckets();
|
const bucketDetailsQueries = useBucketsWithDetails();
|
||||||
|
|
||||||
const totalUsage = useMemo(() => {
|
const totalUsage = useMemo(() => {
|
||||||
return buckets?.reduce((acc, bucket) => acc + bucket.bytes, 0);
|
return bucketDetailsQueries
|
||||||
}, [buckets]);
|
.map(query => query.data?.bytes)
|
||||||
|
.filter(bytes => bytes != null)
|
||||||
|
.reduce((acc, bytes) => acc + bytes, 0);
|
||||||
|
}, [bucketDetailsQueries]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
|
@ -17,14 +17,19 @@ const CreateKeyDialog = () => {
|
|||||||
const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
|
const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
|
||||||
const form = useForm<CreateKeySchema>({
|
const form = useForm<CreateKeySchema>({
|
||||||
resolver: zodResolver(createKeySchema),
|
resolver: zodResolver(createKeySchema),
|
||||||
defaultValues: { name: "" },
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
isImport: false,
|
||||||
|
accessKeyId: "",
|
||||||
|
secretAccessKey: ""
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const isImport = useWatch({ control: form.control, name: "isImport" });
|
const isImport = useWatch({ control: form.control, name: "isImport" });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) form.setFocus("name");
|
if (isOpen) form.setFocus("name");
|
||||||
}, [isOpen]);
|
}, [isOpen, form]);
|
||||||
|
|
||||||
const createKey = useCreateKey({
|
const createKey = useCreateKey({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
@ -15,7 +15,7 @@ export const useKeys = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useCreateKey = (
|
export const useCreateKey = (
|
||||||
options?: UseMutationOptions<any, Error, CreateKeySchema>
|
options?: UseMutationOptions<unknown, Error, CreateKeySchema>
|
||||||
) => {
|
) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (body) => {
|
mutationFn: async (body) => {
|
||||||
@ -29,7 +29,7 @@ export const useCreateKey = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useRemoveKey = (
|
export const useRemoveKey = (
|
||||||
options?: UseMutationOptions<any, Error, string>
|
options?: UseMutationOptions<unknown, Error, string>
|
||||||
) => {
|
) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id) => api.post("/v2/DeleteKey", { params: { id } }),
|
mutationFn: (id) => api.post("/v2/DeleteKey", { params: { id } }),
|
||||||
|
@ -24,7 +24,7 @@ const KeysPage = () => {
|
|||||||
|
|
||||||
const fetchSecretKey = useCallback(async (id: string) => {
|
const fetchSecretKey = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await api.get("/v2/GetKeyInfo", {
|
const result = await api.get<{ secretAccessKey: string }>("/v2/GetKeyInfo", {
|
||||||
params: { id, showSecretKey: "true" },
|
params: { id, showSecretKey: "true" },
|
||||||
});
|
});
|
||||||
if (!result?.secretAccessKey) {
|
if (!result?.secretAccessKey) {
|
||||||
|
@ -6,9 +6,9 @@ export const createKeySchema = z
|
|||||||
.string()
|
.string()
|
||||||
.min(1, "Key Name is required")
|
.min(1, "Key Name is required")
|
||||||
.regex(/^[a-zA-Z0-9_-]+$/, "Key Name invalid"),
|
.regex(/^[a-zA-Z0-9_-]+$/, "Key Name invalid"),
|
||||||
isImport: z.boolean().nullish(),
|
isImport: z.boolean().default(false),
|
||||||
accessKeyId: z.string().nullish(),
|
accessKeyId: z.string().optional(),
|
||||||
secretAccessKey: z.string().nullish(),
|
secretAccessKey: z.string().optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(v) => !v.isImport || (v.accessKeyId != null && v.accessKeyId.length > 0),
|
(v) => !v.isImport || (v.accessKeyId != null && v.accessKeyId.length > 0),
|
||||||
|
@ -4,3 +4,7 @@ export type Key = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type KeyWithSecret = Key & {
|
||||||
|
secretAccessKey: string;
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user