Merge aaef5d15840aaddda04a16bcb5a535a75630f24a into ee420fbf2946e9f79977615cee5e29192d7da478

This commit is contained in:
Mohammad Raska 2025-09-29 20:35:44 -04:00 committed by GitHub
commit a2d339e7d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 722 additions and 210 deletions

40
.github/workflows/README.md vendored Normal file
View 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
View 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
View File

@ -26,6 +26,7 @@ dist-ssr
.env*
!.env.example
docker-compose.*.yml
!docker-compose.dev.yml
data/
meta/

View File

@ -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

View File

@ -2,7 +2,7 @@
[![image](misc/img/garage-webui.png)](misc/img/garage-webui.png)
A simple admin web UI for [Garage](https://garagehq.deuxfleurs.fr/), a self-hosted, S3-compatible, distributed object storage service.
A modern admin web UI for [Garage v2](https://garagehq.deuxfleurs.fr/), a self-hosted, S3-compatible, distributed object storage service.
[ [Screenshots](misc/SCREENSHOTS.md) | [Install Garage](https://garagehq.deuxfleurs.fr/documentation/quick-start/) | [Garage Git](https://git.deuxfleurs.fr/Deuxfleurs/garage) ]
@ -21,7 +21,7 @@ The Garage Web UI is available as a single executable binary and docker image. Y
### Docker CLI
```sh
$ docker run -p 3909:3909 -v ./garage.toml:/etc/garage.toml:ro --restart unless-stopped --name garage-webui khairul169/garage-webui:latest
docker run -p 3909:3909 -v ./garage.toml:/etc/garage.toml:ro --restart unless-stopped --name garage-webui ghcr.io/khairul169/garage-webui:latest
```
### Docker Compose
@ -45,7 +45,7 @@ services:
- 3903:3903
webui:
image: khairul169/garage-webui:latest
image: ghcr.io/khairul169/garage-webui:latest
container_name: garage-webui
restart: unless-stopped
volumes:
@ -62,21 +62,21 @@ services:
Get the latest binary from the [release page](https://github.com/khairul169/garage-webui/releases/latest) according to your OS architecture. For example:
```sh
$ wget -O garage-webui https://github.com/khairul169/garage-webui/releases/download/1.1.0/garage-webui-v1.1.0-linux-amd64
$ chmod +x garage-webui
$ sudo cp garage-webui /usr/local/bin
wget -O garage-webui https://github.com/khairul169/garage-webui/releases/download/1.1.0/garage-webui-v1.1.0-linux-amd64
chmod +x garage-webui
sudo cp garage-webui /usr/local/bin
```
Run the program with specified `garage.toml` config path.
```sh
$ CONFIG_PATH=./garage.toml garage-webui
CONFIG_PATH=./garage.toml garage-webui
```
If you want to run the program at startup, you may want to create a systemd service.
```sh
$ sudo nano /etc/systemd/system/garage-webui.service
sudo nano /etc/systemd/system/garage-webui.service
```
```
@ -97,27 +97,26 @@ WantedBy=default.target
Then reload and start the garage-webui service.
```sh
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now garage-webui
sudo systemctl daemon-reload
sudo systemctl enable --now garage-webui
```
### Configuration
To simplify installation, the Garage Web UI uses values from the Garage configuration, such as `rpc_public_addr`, `admin.admin_token`, `s3_web.root_domain`, etc.
Example content of `config.toml`:
Example content of `garage.toml` for Garage v2:
```toml
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
metadata_auto_snapshot_interval = "6h"
replication_factor = 3
compression_level = 2
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "localhost:3901" # Required
rpc_public_addr = "localhost:3901" # Required for Web UI
rpc_secret = "YOUR_RPC_SECRET_HERE"
[s3_api]
@ -130,7 +129,7 @@ bind_addr = "[::]:3902"
root_domain = ".web.domain.com"
index = "index.html"
[admin] # Required
[admin] # Required for Web UI
api_bind_addr = "[::]:3903"
admin_token = "YOUR_ADMIN_TOKEN_HERE"
metrics_token = "YOUR_METRICS_TOKEN_HERE"
@ -181,29 +180,38 @@ This project is bootstrapped using TypeScript & React for the UI, and Go for bac
### Setup
```sh
$ git clone https://github.com/khairul169/garage-webui.git
$ cd garage-webui && pnpm install
$ cd backend && pnpm install && cd ..
git clone https://github.com/khairul169/garage-webui.git
cd garage-webui && pnpm install
cd backend && pnpm install && cd ..
```
### Running
### Development with Docker
Start both the client and server concurrently:
For development with Docker, a `docker-compose.dev.yml` file is provided with 4 Garage v2 instances:
```sh
$ pnpm run dev # or npm run dev
# Create necessary directories for Garage data
mkdir -p dev.local/data-garage/meta dev.local/data-garage/data
mkdir -p dev.local/data-garage2/meta dev.local/data-garage2/data
mkdir -p dev.local/data-garage3/meta dev.local/data-garage3/data
mkdir -p dev.local/data-garage4/meta dev.local/data-garage4/data
# Generate a secure RPC secret using OpenSSL
# The rpc_secret is used to secure communication between Garage nodes
RPC_SECRET=$(openssl rand -hex 32)
echo "Generated RPC secret: $RPC_SECRET"
# Copy the template configuration files and replace CONTAINER_NAME with the actual container name
# Using sed with empty string after -i for macOS compatibility
cp garage.toml.template dev.local/garage.toml && sed -i '' "s/CONTAINER_NAME/garage/g; s/dev-garage-secret/$RPC_SECRET/g" dev.local/garage.toml
cp garage.toml.template dev.local/garage2.toml && sed -i '' "s/CONTAINER_NAME/garage2/g; s/dev-garage-secret/$RPC_SECRET/g" dev.local/garage2.toml
cp garage.toml.template dev.local/garage3.toml && sed -i '' "s/CONTAINER_NAME/garage3/g; s/dev-garage-secret/$RPC_SECRET/g" dev.local/garage3.toml
cp garage.toml.template dev.local/garage4.toml && sed -i '' "s/CONTAINER_NAME/garage4/g; s/dev-garage-secret/$RPC_SECRET/g" dev.local/garage4.toml
# Setup environment variables
cp .env.example .env
cp backend/.env.example backend/.env
# Start the Garage containers
docker-compose -f docker-compose.dev.yml up -d
```
Or start each instance separately:
```sh
$ pnpm run dev:client
$ cd backend
$ pnpm run dev:server
```
## Troubleshooting
Make sure you are using the latest version of Garage. If the data cannot be loaded, please check whether your instance of Garage has the admin API enabled and the ports are accessible.
If you encounter any problems, please do not hesitate to submit an issue [here](https://github.com/khairul169/garage-webui/issues). You can describe the problem and attach the error logs.

View File

@ -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
View 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

View File

@ -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
View 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"

View File

@ -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]);

View File

@ -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>
);
}
);

View File

@ -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 (

View File

@ -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;
});

View File

@ -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;
};

View File

@ -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);

View File

@ -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",

View File

@ -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,

View File

@ -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));
};

View File

@ -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>

View File

@ -22,7 +22,7 @@ const CreateBucketDialog = () => {
useEffect(() => {
if (isOpen) form.setFocus("globalAlias");
}, [isOpen]);
}, [isOpen, form]);
const createBucket = useCreateBucket({
onSuccess: () => {

View File

@ -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 }),

View File

@ -86,7 +86,7 @@ const CreateFolderAction = ({ prefix }: CreateFolderActionProps) => {
useEffect(() => {
if (isOpen) form.setFocus("name");
}, [isOpen]);
}, [isOpen, form]);
const createFolder = usePutObject(bucketName, {
onSuccess: () => {

View File

@ -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);

View File

@ -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) =>

View File

@ -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"
}?`
)
) {

View File

@ -0,0 +1,3 @@
import { createDisclosure } from "@/lib/disclosure";
export const shareDialog = createDisclosure<{ key: string; prefix: string }>();

View File

@ -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}

View File

@ -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 } }),

View File

@ -60,7 +60,7 @@ const AddAliasDialog = ({ id }: { id?: string }) => {
useEffect(() => {
if (isOpen) form.setFocus("alias");
}, [isOpen]);
}, [isOpen, form]);
const addAlias = useAddAlias(id, {
onSuccess: () => {

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -54,7 +54,7 @@ const AllowKeyDialog = ({ currentKeys }: Props) => {
}));
form.setValue("keys", _keys || []);
}, [keys, currentKeys]);
}, [keys, currentKeys, form]);
const onToggleAll = (
e: React.ChangeEvent<HTMLInputElement>,

View File

@ -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

View File

@ -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>;

View File

@ -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;
};

View File

@ -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}>

View File

@ -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>

View File

@ -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,
});
};

View File

@ -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>;

View File

@ -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 = {

View File

@ -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">

View File

@ -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: () => {

View File

@ -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 } }),

View File

@ -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) {

View File

@ -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),

View File

@ -4,3 +4,7 @@ export type Key = {
id: string;
name: string;
};
export type KeyWithSecret = Key & {
secretAccessKey: string;
};