Compare commits

..

41 Commits

Author SHA1 Message Date
garage-ui-release-bot[bot] f1f30c5c07 Update index.yaml
Signed-off-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-07-14 22:02:26 +00:00
garage-ui-release-bot[bot] 597f93d3af Update index.yaml
Signed-off-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-07-11 15:59:52 +00:00
garage-ui-release-bot[bot] fb4cccf71f Update index.yaml
Signed-off-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-07-03 09:13:56 +00:00
garage-ui-release-bot[bot] 739c51c7db Update index.yaml
Signed-off-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-06-22 13:33:24 +00:00
garage-ui-release-bot[bot] 8a2fd6bb98 Update index.yaml
Signed-off-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-06-22 10:24:16 +00:00
garage-ui-release-bot[bot] a56cf35b0b Update index.yaml
Signed-off-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-05-31 10:51:46 +00:00
garage-ui-release-bot[bot] 237c920cb9 Update index.yaml
Signed-off-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-05-31 09:58:02 +00:00
Noooste bcf91a3a5f Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2026-05-25 16:52:15 +00:00
garage-ui-release-bot[bot] 98032126b3 Update index.yaml
Signed-off-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-05-16 08:27:52 +00:00
garage-ui-release-bot[bot] 781a5b11a8 Update index.yaml
Signed-off-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-05-15 21:30:15 +00:00
Noooste 29c960c2e5 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2026-04-19 21:30:06 +00:00
Noooste bd1461c9d5 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2026-04-19 20:51:00 +00:00
Noooste 88d77fb271 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2026-04-19 15:16:00 +00:00
Noooste f504a83355 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2026-04-13 22:07:32 +00:00
Noooste 040e7b1796 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2026-04-13 20:24:00 +00:00
Noste 27c03da35e Create CNAME 2026-03-07 14:33:14 +01:00
Noste 93ccbeefbc Delete CNAME 2026-03-07 14:33:10 +01:00
Noooste c3cad9cea1 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2026-03-07 13:30:46 +00:00
Noooste 188df5b4c2 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2026-03-07 09:57:06 +00:00
Noooste f949823549 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-26 15:02:29 +00:00
Noooste e93984a938 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-26 14:44:53 +00:00
Noooste 31a00a7460 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-26 14:38:20 +00:00
Noooste f998dd0383 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-26 14:31:48 +00:00
Noooste 2cbb0c8357 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-26 14:29:05 +00:00
Noooste 7140d40779 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-26 11:30:30 +00:00
Noooste 434a1ecca6 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-22 11:15:58 +00:00
Noooste 8a3ceaa4a6 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-21 23:15:49 +00:00
Noooste 97047d3289 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-21 23:10:43 +00:00
Noste 1f9576547f Add official flag to artifacthub-repo.yml 2025-12-22 00:05:40 +01:00
Noste d86d3ec49f Update artifacthub-repo.yml 2025-12-22 00:01:53 +01:00
Noste 2f0c0f994c Add files via upload 2025-12-21 23:59:58 +01:00
Noooste 4c998bd7b2 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-21 22:53:32 +00:00
Noooste a9c343adad Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-21 14:54:22 +00:00
Noooste 0b869e946c Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-21 13:09:15 +00:00
Noooste a3e3a0f6a9 Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-20 12:09:27 +00:00
Noste 55c01041c0 Add artifacthub-repo.yml 2025-12-20 10:36:53 +01:00
Noste 40a23cdc19 Update CNAME 2025-12-20 10:30:59 +01:00
Noooste 833293af9c Update index.yaml
Signed-off-by: Noooste <Noooste@users.noreply.github.com>
2025-12-20 09:20:16 +00:00
Noste 11124989d7 Create CNAME 2025-12-20 10:02:28 +01:00
Noste 3a91701d38 Initial gh-pages 2025-12-20 09:53:00 +01:00
Noste 30804c85ad Initial gh-pages 2025-12-20 09:51:18 +01:00
108 changed files with 760 additions and 20043 deletions
-46
View File
@@ -1,46 +0,0 @@
# Git files
.git
.gitignore
# IDE files
.idea
.vscode
*.swp
*.swo
*~
# Build artifacts
bin/
*.exe
*.dll
*.so
*.dylib
# Test files
*.test
coverage.txt
coverage.html
# Frontend development files
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/.env
frontend/.env.local
# Backend development files
*.log
tmp/
temp/
# Documentation (optional, include if needed in image)
# docs/
# OS files
.DS_Store
Thumbs.db
# Development config (you may want a separate config for production)
*.local.yaml
-114
View File
@@ -1,114 +0,0 @@
name: Docker Build and Push
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKER_LOGIN }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_REGISTRY }}
tags: |
type=semver,pattern={{raw}}
type=raw,value=latest
- name: Build and push by digest
id: build
uses: docker/build-push-action@v5
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
outputs: type=image,name=${{ secrets.DOCKER_REGISTRY }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ strategy.job-index }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKER_LOGIN }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_REGISTRY }}
tags: |
type=semver,pattern={{raw}}
type=raw,value=latest
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ secrets.DOCKER_REGISTRY }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ secrets.DOCKER_REGISTRY }}:${{ steps.meta.outputs.version }}
-37
View File
@@ -1,37 +0,0 @@
name: Release Charts
on:
push:
branches:
- main
paths:
- 'helm/garage-ui/**'
- '!helm/garage-ui/README.md'
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Set up Helm
uses: azure/setup-helm@v4.3.1
with:
version: v3.19.3
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.7.0
with:
charts_dir: helm
env:
CR_TOKEN: "${{ secrets.HELM_RELEASE_TOKEN }}"
-63
View File
@@ -1,63 +0,0 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env*
!config.yaml.example
docker-compose.*.yml
data/
meta/
garage.toml
backend/docs/
config.yaml
+1
View File
@@ -0,0 +1 @@
helm.noste.dev
-56
View File
@@ -1,56 +0,0 @@
FROM node:25-alpine3.22 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
npm install
COPY frontend/ .
RUN npm run build
FROM golang:1.25.4-alpine3.22 AS backend-builder
WORKDIR /app
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go install github.com/swaggo/swag/cmd/swag@latest
COPY backend/go.mod backend/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY backend .
RUN --mount=type=cache,target=/root/.cache/go-build \
swag init
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o garage-ui .
FROM alpine:3.22
WORKDIR /app
RUN apk --no-cache add ca-certificates wget
RUN addgroup -g 1000 garageui && \
adduser -D -u 1000 -G garageui garageui
COPY --from=backend-builder --chown=garageui:garageui /app/garage-ui .
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
USER garageui
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["./garage-ui"]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Noste
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-647
View File
@@ -1,647 +0,0 @@
# Garage UI
A modern, full-stack web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems. Built with React, TypeScript, Go, and Fiber for production-ready S3-compatible storage management.
[![Docker Build](https://github.com/Noooste/garage-ui/actions/workflows/build.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
[![Helm Chart](https://github.com/Noooste/garage-ui/actions/workflows/release.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go)](https://go.dev/)
[![Node Version](https://img.shields.io/badge/Node-25%2B-339933?logo=node.js)](https://nodejs.org/)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui)](https://artifacthub.io/packages/search?repo=garage-ui)
Garage UI provides a comprehensive dashboard for managing your Garage S3 storage cluster, featuring bucket management, object operations, user access control, and real-time cluster monitoring—all through an intuitive web interface.
---
## Features
- **Bucket Management** - Create, configure, and delete S3 buckets with visual controls
- **Object Operations** - Upload, download, and manage objects with drag-and-drop support
- **User & Access Control** - Manage access keys, permissions, and user credentials
- **Cluster Monitoring** - Real-time cluster health metrics, node status, and performance statistics
- **Multi-Auth Support** - Flexible authentication with None, Basic Auth, and OIDC/OAuth2 modes
- **Modern UI/UX** - Responsive React interface with dark mode, built on Tailwind CSS and shadcn/ui
- **Production Ready** - Docker/Kubernetes deployment, health checks, and Prometheus metrics
- **API-First Design** - RESTful API with Swagger/OpenAPI documentation
---
## Quick Start
### Prerequisites
- **Garage S3** storage cluster running (v2.0.0+)
- **Docker** & Docker Compose (recommended) OR
- **Go** 1.25+ and **Node.js** 25+ (for development)
### Using Docker Compose (Recommended)
1. **Clone the repository:**
```bash
git clone https://github.com/Noooste/garage-ui.git
cd garage-ui
```
2. **Configure the application:**
```bash
cp config.yaml.example config.yaml
# Edit config.yaml with your Garage endpoints and credentials
```
3. **Start the services:**
```bash
docker-compose up -d
```
4. **Access the UI:**
Open your browser to [http://localhost:8080](http://localhost:8080)
> The Docker Compose setup includes both Garage and Garage UI services for quick local development.
### Using Docker
```bash
docker run -d \
-p 8080:8080 \
-v $(pwd)/config.yaml:/app/config.yaml \
-e GARAGE_UI_GARAGE_ENDPOINT=http://your-garage:3900 \
-e GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://your-garage:3903 \
noooste/garage-ui:latest
```
---
## Installation
### Docker Deployment
**Pull the latest image:**
```bash
docker pull noooste/garage-ui:latest
```
**Available tags:**
- `latest` - Latest stable release
- `v0.0.4` - Specific version
- `0.0` - Minor version track
The Docker image is multi-platform, supporting `linux/amd64` and `linux/arm64`.
### Kubernetes Deployment with Helm
```bash
# Add the Helm repository (if available)
helm repo add garage-ui https://helm.noste.dev/
# Install the chart
helm install garage-ui garage-ui/garage-ui \
--set garage.endpoint=http://garage:3900 \
--set garage.adminEndpoint=http://garage:3903 \
--set garage.adminToken=your-admin-token
```
Or use the local Helm chart:
```bash
helm install garage-ui ./helm/garage-ui -f custom-values.yaml
```
### Building from Source
**Backend:**
```bash
cd backend
go mod download
swag init # Generate API docs
go build -o garage-ui .
```
**Frontend:**
```bash
cd frontend
npm install
npm run build
```
**Run:**
```bash
# From project root
./backend/garage-ui --config config.yaml
```
---
## Configuration
Garage UI can be configured via YAML file or environment variables.
### Configuration File
Create a `config.yaml` based on the provided example:
```yaml
# Server configuration
server:
host: "0.0.0.0"
port: 8080
environment: "production"
# Garage S3 Configuration
garage:
endpoint: "http://garage:3900"
region: "eu-west-1"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token-here"
# Authentication (none, basic, oidc)
auth:
mode: "none"
# Basic auth example
basic:
username: "admin"
password: "secure-password"
# OIDC example (Keycloak, Auth0, etc.)
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
issuer_url: "https://auth.example.com/realms/master"
auth_url: "https://auth.example.com/realms/master/protocol/openid-connect/auth"
token_url: "https://auth.example.com/realms/master/protocol/openid-connect/token"
```
See [`config.yaml.example`](config.yaml.example) for all available options.
### Environment Variables
All configuration can be set via environment variables with the prefix `GARAGE_UI_`:
```bash
GARAGE_UI_SERVER_PORT=8080
GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900
GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://garage:3903
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
GARAGE_UI_AUTH_MODE=basic
GARAGE_UI_AUTH_BASIC_USERNAME=admin
GARAGE_UI_AUTH_BASIC_PASSWORD=password
GARAGE_UI_LOGGING_LEVEL=info
```
**Configuration Priority:** Environment variables override YAML file settings.
---
## Project Structure
```
garage-ui/
├── backend/ # Go backend (Fiber framework)
│ ├── internal/
│ │ ├── auth/ # Authentication services (JWT, OIDC)
│ │ ├── config/ # Configuration loading
│ │ ├── handlers/ # HTTP request handlers
│ │ ├── middleware/ # CORS, auth middleware
│ │ ├── models/ # Data models and types
│ │ ├── routes/ # API route definitions
│ │ └── services/ # Business logic (S3, Garage Admin)
│ ├── pkg/ # Shared packages
│ ├── main.go # Application entry point
│ └── go.mod # Go dependencies
├── frontend/ # React + TypeScript frontend
│ ├── src/
│ │ ├── components/ # Reusable UI components
│ │ │ ├── buckets/ # Bucket-specific components
│ │ │ ├── charts/ # Data visualization
│ │ │ ├── layout/ # App layout and navigation
│ │ │ └── ui/ # shadcn/ui component library
│ │ ├── hooks/ # Custom React hooks
│ │ ├── lib/ # API client and utilities
│ │ ├── pages/ # Page components (Dashboard, Buckets, etc.)
│ │ ├── types/ # TypeScript type definitions
│ │ └── App.tsx # Main application component
│ ├── package.json # Node dependencies
│ └── vite.config.ts # Vite build configuration
├── helm/ # Kubernetes Helm chart
│ └── garage-ui/
│ ├── Chart.yaml # Chart metadata
│ ├── values.yaml # Default values
│ └── templates/ # K8s resource templates
├── .github/workflows/ # CI/CD pipelines
│ ├── build.yml # Docker build and push
│ └── release.yml # Helm chart releases
├── Dockerfile # Multi-stage build
├── docker-compose.yml # Local development stack
└── config.yaml.example # Configuration template
```
**Key Directories:**
- **`backend/internal/handlers/`** - REST API endpoints for buckets, objects, users, cluster, and monitoring
- **`frontend/src/pages/`** - Main application views (Dashboard, Buckets, Cluster, Access Control)
- **`backend/internal/services/`** - Core business logic interfacing with Garage Admin API and S3
- **`frontend/src/lib/api.ts`** - Axios-based API client with automatic error handling
---
## Development
### Local Development Setup
**1. Start Garage (using Docker Compose):**
```bash
docker-compose up garage -d
```
**2. Run the backend:**
```bash
cd backend
go run main.go --config ../config.yaml
```
The backend will start on `http://localhost:8080` with hot-reload (use [air](https://github.com/cosmtrek/air) for auto-reload).
**3. Run the frontend:**
```bash
cd frontend
npm install
npm run dev
```
The frontend dev server starts on `http://localhost:3000` with API proxy to backend.
### Available Scripts
**Backend:**
```bash
go run main.go # Start server
go test ./... # Run tests
swag init # Regenerate API docs
go build -o garage-ui . # Build binary
```
**Frontend:**
```bash
npm run dev # Development server (Vite)
npm run build # Production build
npm run lint # ESLint checks
npm run preview # Preview production build
```
### Testing
```bash
# Backend tests
cd backend && go test ./internal/...
# Frontend tests (if available)
cd frontend && npm test
```
### Building Docker Image
```bash
docker build -t garage-ui:dev .
```
The Dockerfile uses a multi-stage build:
1. **Frontend build** - Node.js Alpine to compile React app
2. **Backend build** - Go Alpine to compile binary
3. **Runtime** - Minimal Alpine with ca-certificates
---
## API Documentation
Once the backend is running, access the Swagger UI documentation at:
**http://localhost:8080/api/v1/**
### API Endpoints Overview
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check endpoint |
| `/api/v1/buckets` | GET | List all buckets |
| `/api/v1/buckets` | POST | Create a new bucket |
| `/api/v1/buckets/{name}` | GET | Get bucket details |
| `/api/v1/buckets/{name}` | DELETE | Delete a bucket |
| `/api/v1/buckets/{name}/objects` | GET | List objects in bucket |
| `/api/v1/objects/upload` | POST | Upload object to bucket |
| `/api/v1/objects/download` | GET | Download object |
| `/api/v1/objects/delete` | DELETE | Delete object |
| `/api/v1/users` | GET | List access keys |
| `/api/v1/users` | POST | Create access key |
| `/api/v1/users/{id}` | DELETE | Delete access key |
| `/api/v1/cluster/status` | GET | Cluster node status |
| `/api/v1/cluster/health` | GET | Cluster health metrics |
| `/api/v1/cluster/statistics` | GET | Storage statistics |
| `/api/v1/metrics` | GET | Prometheus metrics |
**Authentication:** Bearer token in `Authorization` header when using OIDC or session-based auth.
---
## Authentication Modes
Garage UI supports three authentication modes:
### 1. None (Default)
No authentication required - suitable for private networks or development.
```yaml
auth:
mode: "none"
```
### 2. Basic Authentication
Simple username/password authentication.
```yaml
auth:
mode: "basic"
basic:
username: "admin"
password: "your-secure-password"
```
### 3. OIDC (OpenID Connect)
Enterprise-grade authentication with providers like Keycloak, Auth0, Okta, etc.
```yaml
auth:
mode: "oidc"
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
issuer_url: "https://auth.example.com/realms/master"
auth_url: "https://auth.example.com/realms/master/protocol/openid-connect/auth"
token_url: "https://auth.example.com/realms/master/protocol/openid-connect/token"
userinfo_url: "https://auth.example.com/realms/master/protocol/openid-connect/userinfo"
session_max_age: 86400 # 24 hours
cookie_secure: true # Enable in production with HTTPS
```
**Role-Based Access (Optional):**
```yaml
auth:
oidc:
role_attribute_path: "resource_access.garage-ui.roles"
admin_role: "admin"
```
---
## Deployment Best Practices
### Production Considerations
1. **Use HTTPS** - Always enable TLS in production:
```yaml
auth:
oidc:
cookie_secure: true
```
2. **Set Strong Admin Token** - Generate a secure token for Garage Admin API:
```bash
openssl rand -base64 32
```
3. **Configure CORS** - Restrict allowed origins:
```yaml
cors:
allowed_origins:
- "https://garage-ui.yourdomain.com"
```
4. **Enable Monitoring** - Expose metrics endpoint for Prometheus:
```yaml
# Metrics available at /api/v1/metrics
```
5. **Resource Limits** - Set appropriate CPU/memory limits in Kubernetes/Docker
### Health Checks
The application provides a health check endpoint:
```bash
curl http://localhost:8080/health
# Response: {"status":"ok","version":"0.1.0"}
```
Docker health check is configured automatically (every 30s).
### Kubernetes Deployment
The Helm chart includes:
- **Deployment** with configurable replicas
- **Service** (ClusterIP/LoadBalancer)
- **Ingress** with TLS support
- **ConfigMap** for configuration
- **NetworkPolicy** for security
- **ServiceMonitor** for Prometheus (optional)
Example custom values:
```yaml
# custom-values.yaml
replicaCount: 2
image:
repository: noooste/garage-ui
tag: v0.0.4
garage:
endpoint: "http://garage.storage.svc.cluster.local:3900"
adminEndpoint: "http://garage.storage.svc.cluster.local:3903"
adminToken: "your-secure-token"
ingress:
enabled: true
className: nginx
hosts:
- host: garage-ui.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-ui-tls
hosts:
- garage-ui.example.com
auth:
mode: oidc
oidc:
clientId: garage-ui
clientSecret: secret
issuerUrl: https://auth.example.com/realms/master
```
Install with:
```bash
helm install garage-ui ./helm/garage-ui -f custom-values.yaml
```
---
## Technology Stack
### Backend
- **[Go](https://go.dev/)** 1.25+ - High-performance backend runtime
- **[Fiber v3](https://gofiber.io/)** - Express-inspired web framework
- **[Viper](https://github.com/spf13/viper)** - Configuration management
- **[Minio Go SDK](https://github.com/minio/minio-go)** - S3 client library
- **[go-oidc](https://github.com/coreos/go-oidc)** - OpenID Connect support
- **[Zerolog](https://github.com/rs/zerolog)** - Structured logging
- **[Swagger/Swag](https://github.com/swaggo/swag)** - API documentation
### Frontend
- **[React](https://react.dev/)** 19 - UI framework
- **[TypeScript](https://www.typescriptlang.org/)** 5.9+ - Type safety
- **[Vite](https://vitejs.dev/)** 7 - Build tool and dev server
- **[React Router](https://reactrouter.com/)** 7 - Client-side routing
- **[TanStack Query](https://tanstack.com/query)** - Server state management
- **[Zustand](https://github.com/pmndrs/zustand)** - Client state management
- **[React Hook Form](https://react-hook-form.com/)** + [Zod](https://zod.dev/) - Form validation
- **[Tailwind CSS](https://tailwindcss.com/)** 4 - Utility-first styling
- **[shadcn/ui](https://ui.shadcn.com/)** - Component library
- **[Lucide Icons](https://lucide.dev/)** - Icon library
- **[Recharts](https://recharts.org/)** - Data visualization
- **[Axios](https://axios-http.com/)** - HTTP client
---
## Contributing
Contributions are welcome! Whether it's bug reports, feature requests, or code contributions, your input helps improve Garage UI.
### How to Contribute
1. **Fork the repository**
2. **Create a feature branch** (`git checkout -b feature/amazing-feature`)
3. **Make your changes**
4. **Commit your changes** (`git commit -m 'Add amazing feature'`)
5. **Push to the branch** (`git push origin feature/amazing-feature`)
6. **Open a Pull Request**
### Development Guidelines
- Follow existing code style (use `gofmt` for Go, ESLint for TypeScript)
- Add tests for new features when possible
- Update documentation for API changes
- Keep commits atomic and descriptive
- Ensure all tests pass before submitting PR
### Reporting Issues
Please open an issue on GitHub with:
- Clear description of the problem
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS, Docker version, etc.)
---
## Troubleshooting
### Common Issues
**Issue: "Failed to connect to Garage Admin API"**
- **Solution:** Verify `garage.admin_endpoint` and `garage.admin_token` in config.yaml
- Check Garage Admin API is accessible: `curl http://garage:3903/status -H "Authorization: Bearer your-token"`
**Issue: "CORS errors in browser console"**
- **Solution:** Configure CORS in config.yaml to allow frontend origin:
```yaml
cors:
allowed_origins:
- "http://localhost:3000" # For dev
```
**Issue: "401 Unauthorized with OIDC"**
- **Solution:** Verify OIDC configuration, especially `issuer_url`, `client_id`, and `client_secret`
- Check provider configuration allows the redirect URL: `http://your-app/auth/callback`
**Issue: "Objects not appearing after upload"**
- **Solution:** Check S3 endpoint connectivity and bucket permissions
- Verify Garage bucket exists: `garage bucket list`
### Debug Mode
Enable debug logging for troubleshooting:
```yaml
logging:
level: "debug"
format: "json"
```
Or via environment variable:
```bash
GARAGE_UI_LOGGING_LEVEL=debug
```
---
## License
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
Copyright (c) 2025 Noste
---
## Acknowledgments
- [Garage](https://garagehq.deuxfleurs.fr/) - The lightweight, geo-distributed S3 storage system
- [shadcn/ui](https://ui.shadcn.com/) - Beautiful, accessible React components
- [Fiber](https://gofiber.io/) - Fast and lightweight Go web framework
- All open-source contributors who made this project possible
---
## Support
- **Documentation:** [GitHub Wiki](https://github.com/Noooste/garage-ui/wiki) (coming soon)
- **Issues:** [GitHub Issues](https://github.com/Noooste/garage-ui/issues)
- **Discussions:** [GitHub Discussions](https://github.com/Noooste/garage-ui/discussions)
---
**Made with ❤️ for the Garage community**
+6
View File
@@ -0,0 +1,6 @@
repositoryID: bcb67b2e-42ae-4cb8-8f03-5426e3c839fd
owners:
- name: Noooste
email: 83548733+Noooste@users.noreply.github.com
logoURL: https://helm.noste.dev/garage.png
official: true
-84
View File
@@ -1,84 +0,0 @@
module Noooste/garage-ui
go 1.25.3
require (
github.com/Noooste/azuretls-client v1.12.10
github.com/Noooste/swagger v1.2.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/gofiber/fiber/v3 v3.0.0-rc.3
github.com/minio/minio-go/v7 v7.0.97
github.com/rs/zerolog v1.34.0
github.com/spf13/viper v1.21.0
github.com/swaggo/swag v1.16.6
golang.org/x/oauth2 v0.33.0
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Noooste/fhttp v1.0.15 // indirect
github.com/Noooste/go-socks4 v0.0.2 // indirect
github.com/Noooste/uquic-go v1.0.3 // indirect
github.com/Noooste/utls v1.3.20 // indirect
github.com/Noooste/websocket v1.0.3 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bdandy/go-errors v1.2.2 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gaukas/clienthellod v0.4.2 // indirect
github.com/gaukas/godicttls v0.0.4 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-rc.3 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/gopacket v1.1.19 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/refraction-networking/utls v1.8.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.68.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.39.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-239
View File
@@ -1,239 +0,0 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Noooste/azuretls-client v1.12.9 h1:w6XVao95irzge8k+yF5ZovIKf7UBe0GVE3sTRKs/4wE=
github.com/Noooste/azuretls-client v1.12.9/go.mod h1:GHqLaS+vjBk+D3fMA0d+gNgDS2ahtic+6c+7JyMfrdU=
github.com/Noooste/azuretls-client v1.12.10 h1:wD+hSokcB1DCFSSLpj05bMGUCBV0wbPB/Z4uPFNEpoI=
github.com/Noooste/azuretls-client v1.12.10/go.mod h1:nVPwYA6UgHrOinlpvj6t/zDTBV+UfT3t8vmab7WYxF0=
github.com/Noooste/fhttp v1.0.15 h1:sYRWOKgr1x4L+wA6REMJCs4Z/lFOSJmuQHSIXMXCcPs=
github.com/Noooste/fhttp v1.0.15/go.mod h1:YZtq+i2M11Y22UiOR6gjNSLMNLiPhURh6M44oFVQ1TE=
github.com/Noooste/go-socks4 v0.0.2 h1:DwHCYiCEAdjfNrQOFIid7qgKCll7ubhGS1ji5O8FYng=
github.com/Noooste/go-socks4 v0.0.2/go.mod h1:+oOgtOFRsU8FoK7NBOhHSjiH5pveY8LgYNF5XcqVgjE=
github.com/Noooste/swagger v1.2.0 h1:zGHin8k2V9mXDB1gxXOdKe4V8zhw79ycsw+/L2hH/pk=
github.com/Noooste/swagger v1.2.0/go.mod h1:5N+iUZlFA43k2Paf42EZ+SFndBG1niSA1FAnwiNP1PM=
github.com/Noooste/uquic-go v1.0.1 h1:12ARejbnh0R5FLGoHhz4p+qT/8BKkRNTPsJlkX4/pg8=
github.com/Noooste/uquic-go v1.0.1/go.mod h1:6AUIck22N0wQ5+CY/5pLmW3IzdITlf7ABtjbRsaZq2A=
github.com/Noooste/uquic-go v1.0.3 h1:VP8npQmU4lkVLm9Ug5Q18SJ8ExFDfUZIzd13YjYaLHE=
github.com/Noooste/uquic-go v1.0.3/go.mod h1:MxkrvgpNcbIOSQxqglC3e/798O/6zuL3mBhlFN+04w4=
github.com/Noooste/utls v1.3.20 h1:QzBNGGJ184bNMLodOzvM9YWc4vZ36QodIjqFQOHoZ88=
github.com/Noooste/utls v1.3.20/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg=
github.com/Noooste/websocket v1.0.3 h1:drW7tvZ3YqzqI9wApnaH1Q0syFMXO7gbLlsBWjZvMNA=
github.com/Noooste/websocket v1.0.3/go.mod h1:Qhw0Rtuju/fPPbcb3R5XGq7poa51qPDL462jTltl9nQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr7eqE=
github.com/gaukas/clienthellod v0.4.2/go.mod h1:M57+dsu0ZScvmdnNxaxsDPM46WhSEdPYAOdNgfL7IKA=
github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk=
github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v3 v3.0.0-rc.3 h1:h0KXuRHbivSslIpoHD1R/XjUsjcGwt+2vK0avFiYonA=
github.com/gofiber/fiber/v3 v3.0.0-rc.3/go.mod h1:LNBPuS/rGoUFlOyy03fXsWAeWfdGoT1QytwjRVNSVWo=
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
github.com/gofiber/utils/v2 v2.0.0-rc.2 h1:NvJTf7yMafTq16lUOJv70nr+HIOLNQcvGme/X+ftbW8=
github.com/gofiber/utils/v2 v2.0.0-rc.2/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI=
github.com/gofiber/utils/v2 v2.0.0-rc.3 h1:gOL5jAEGUT2UbQkTkgMJctYt4rYewnTIt0Y7YaDATDc=
github.com/gofiber/utils/v2 v2.0.0-rc.3/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.80 h1:2mdUHXEykRdY/BigLt3Iuu1otL0JTogT0Nmltg0wujk=
github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/shamaton/msgpack/v2 v2.4.0 h1:O5Z08MRmbo0lA9o2xnQ4TXx6teJbPqEurqcCOQ8Oi/4=
github.com/shamaton/msgpack/v2 v2.4.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc=
github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-339
View File
@@ -1,339 +0,0 @@
package auth
import (
"context"
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"Noooste/garage-ui/internal/config"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// AuthService handles authentication operations
type AuthService struct {
config *config.AuthConfig
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oauth2Config *oauth2.Config
jwtService *JWTService
}
// UserInfo represents authenticated user information
type UserInfo struct {
Username string
Email string
Name string
Roles []string
}
// NewAuthService creates a new authentication service
func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) {
jwtService, err := NewJWTService()
if err != nil {
return nil, fmt.Errorf("failed to initialize JWT service: %w", err)
}
service := &AuthService{
config: cfg,
jwtService: jwtService,
}
// Initialize OIDC if enabled
if cfg.Mode == "oidc" && cfg.OIDC.Enabled {
if err := service.initOIDC(); err != nil {
return nil, fmt.Errorf("failed to initialize OIDC: %w", err)
}
}
return service, nil
}
// initOIDC initializes the OIDC provider and configuration
func (a *AuthService) initOIDC() error {
ctx := context.Background()
// Create OIDC provider
provider, err := oidc.NewProvider(ctx, a.config.OIDC.IssuerURL)
if err != nil {
return fmt.Errorf("failed to create OIDC provider: %w", err)
}
a.oidcProvider = provider
// Create ID token verifier
verifierConfig := &oidc.Config{
ClientID: a.config.OIDC.ClientID,
SkipIssuerCheck: a.config.OIDC.SkipIssuerCheck,
SkipExpiryCheck: a.config.OIDC.SkipExpiryCheck,
}
a.oidcVerifier = provider.Verifier(verifierConfig)
// Create OAuth2 config
a.oauth2Config = &oauth2.Config{
ClientID: a.config.OIDC.ClientID,
ClientSecret: a.config.OIDC.ClientSecret,
Endpoint: provider.Endpoint(),
Scopes: a.config.OIDC.Scopes,
}
return nil
}
// ValidateBasicAuth validates basic authentication credentials
func (a *AuthService) ValidateBasicAuth(username, password string) bool {
// Use constant-time comparison to prevent timing attacks
usernameMatch := subtle.ConstantTimeCompare(
[]byte(username),
[]byte(a.config.Basic.Username),
) == 1
passwordMatch := subtle.ConstantTimeCompare(
[]byte(password),
[]byte(a.config.Basic.Password),
) == 1
return usernameMatch && passwordMatch
}
// ParseBasicAuth parses the Authorization header for basic auth
func ParseBasicAuth(authHeader string) (username, password string, ok bool) {
if authHeader == "" {
return "", "", false
}
// Check if it's a Basic auth header
const prefix = "Basic "
if !strings.HasPrefix(authHeader, prefix) {
return "", "", false
}
// Decode base64 credentials
encoded := authHeader[len(prefix):]
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", false
}
// Split username:password
credentials := string(decoded)
parts := strings.SplitN(credentials, ":", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
// GetAuthorizationURL returns the OIDC authorization URL for login
func (a *AuthService) GetAuthorizationURL(state string) (string, error) {
if a.oauth2Config == nil {
return "", fmt.Errorf("OIDC not initialized")
}
return a.oauth2Config.AuthCodeURL(state), nil
}
// ExchangeCode exchanges an authorization code for tokens
func (a *AuthService) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
if a.oauth2Config == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
token, err := a.oauth2Config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
return token, nil
}
// VerifyIDToken verifies an OIDC ID token and extracts user info
func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserInfo, error) {
if a.oidcVerifier == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
// Verify the ID token
idToken, err := a.oidcVerifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("failed to verify ID token: %w", err)
}
// Extract claims
var claims map[string]interface{}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to parse claims: %w", err)
}
// Extract user information using configured attributes
userInfo := &UserInfo{
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
}
// Extract roles if configured
if a.config.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
}
return userInfo, nil
}
// GetUserInfo retrieves user information from the OIDC provider
func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
if a.oidcProvider == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
// Create OAuth2 token source
tokenSource := a.oauth2Config.TokenSource(ctx, token)
// Get user info from the provider
userInfoEndpoint, err := a.oidcProvider.UserInfo(ctx, tokenSource)
if err != nil {
return nil, fmt.Errorf("failed to get user info: %w", err)
}
// Extract claims
var claims map[string]interface{}
if err := userInfoEndpoint.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to parse user info claims: %w", err)
}
// Build user info
userInfo := &UserInfo{
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
}
// Extract roles if configured
if a.config.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
}
return userInfo, nil
}
// IsAdmin checks if the user has admin role
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
if a.config.OIDC.AdminRole == "" {
return false
}
for _, role := range userInfo.Roles {
if role == a.config.OIDC.AdminRole {
return true
}
}
return false
}
// Helper functions
// extractClaim extracts a string claim from the claims map
func extractClaim(claims map[string]interface{}, key string) string {
if key == "" {
return ""
}
value, ok := claims[key]
if !ok {
return ""
}
str, ok := value.(string)
if !ok {
return ""
}
return str
}
// extractRoles extracts roles from nested claim path (e.g., "resource_access.garage-ui.roles")
func extractRoles(claims map[string]interface{}, path string) []string {
if path == "" {
return nil
}
// Split the path by dots to navigate nested claims
parts := strings.Split(path, ".")
current := claims
for i, part := range parts {
value, ok := current[part]
if !ok {
return nil
}
if i == len(parts)-1 {
return extractStringArray(value)
}
// Navigate to next level
next, ok := value.(map[string]interface{})
if !ok {
return nil
}
current = next
}
return nil
}
// extractStringArray converts an interface{} to []string if possible
func extractStringArray(value interface{}) []string {
// Try direct string array
if strArray, ok := value.([]string); ok {
return strArray
}
// Try interface array and convert to strings
if array, ok := value.([]interface{}); ok {
result := make([]string, 0, len(array))
for _, item := range array {
if str, ok := item.(string); ok {
result = append(result, str)
}
}
return result
}
return nil
}
// GenerateStateToken generates a secure CSRF state token
func (a *AuthService) GenerateStateToken() (string, error) {
return a.jwtService.GenerateStateToken()
}
// ValidateAndConsumeState validates and consumes a CSRF state token
func (a *AuthService) ValidateAndConsumeState(token string) bool {
return a.jwtService.ValidateAndConsumeState(token)
}
// GenerateSessionToken generates a JWT session token for the user
func (a *AuthService) GenerateSessionToken(userInfo *UserInfo) (string, error) {
return a.jwtService.GenerateToken(userInfo, a.config.OIDC.SessionMaxAge)
}
// ValidateSessionToken validates a JWT session token and returns user info
func (a *AuthService) ValidateSessionToken(tokenString string) (*UserInfo, error) {
claims, err := a.jwtService.ValidateToken(tokenString)
if err != nil {
return nil, err
}
return &UserInfo{
Username: claims.Username,
Email: claims.Email,
Name: claims.Name,
Roles: claims.Roles,
}, nil
}
-163
View File
@@ -1,163 +0,0 @@
package auth
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
type JWTService struct {
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
stateStore *StateStore
}
type StateStore struct {
mu sync.RWMutex
states map[string]StateData
}
type StateData struct {
Created time.Time
ExpiresAt time.Time
}
type SessionClaims struct {
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
func NewJWTService() (*JWTService, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("failed to generate RSA key: %w", err)
}
return &JWTService{
privateKey: privateKey,
publicKey: &privateKey.PublicKey,
stateStore: &StateStore{
states: make(map[string]StateData),
},
}, nil
}
func (j *JWTService) GenerateStateToken() (string, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", fmt.Errorf("failed to generate state token: %w", err)
}
token := base64.URLEncoding.EncodeToString(tokenBytes)
j.stateStore.mu.Lock()
defer j.stateStore.mu.Unlock()
now := time.Now()
j.stateStore.states[token] = StateData{
Created: now,
ExpiresAt: now.Add(10 * time.Minute),
}
go j.cleanupExpiredStates()
return token, nil
}
func (j *JWTService) ValidateAndConsumeState(token string) bool {
j.stateStore.mu.Lock()
defer j.stateStore.mu.Unlock()
state, exists := j.stateStore.states[token]
if !exists {
return false
}
if time.Now().After(state.ExpiresAt) {
delete(j.stateStore.states, token)
return false
}
delete(j.stateStore.states, token)
return true
}
func (j *JWTService) cleanupExpiredStates() {
j.stateStore.mu.Lock()
defer j.stateStore.mu.Unlock()
now := time.Now()
for token, state := range j.stateStore.states {
if now.After(state.ExpiresAt) {
delete(j.stateStore.states, token)
}
}
}
func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (string, error) {
now := time.Now()
expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second)
claims := SessionClaims{
Username: userInfo.Username,
Email: userInfo.Email,
Name: userInfo.Name,
Roles: userInfo.Roles,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
NotBefore: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tokenString, err := token.SignedString(j.privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return tokenString, nil
}
func (j *JWTService) ValidateToken(tokenString string) (*SessionClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &SessionClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return j.publicKey, nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
}
if claims, ok := token.Claims.(*SessionClaims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
func (j *JWTService) GetPublicKeyPEM() (string, error) {
pubKeyBytes, err := x509.MarshalPKIXPublicKey(j.publicKey)
if err != nil {
return "", fmt.Errorf("failed to marshal public key: %w", err)
}
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: pubKeyBytes,
})
return string(pubKeyPEM), nil
}
-252
View File
@@ -1,252 +0,0 @@
package config
import (
"fmt"
"os"
"strings"
"github.com/spf13/viper"
)
// Config represents the application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
Garage GarageConfig `mapstructure:"garage"`
Auth AuthConfig `mapstructure:"auth"`
CORS CORSConfig `mapstructure:"cors"`
Logging LoggingConfig `mapstructure:"logging"`
}
// ServerConfig contains server-related configuration
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Environment string `mapstructure:"environment"`
FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory
}
// GarageConfig contains Garage S3 connection settings
type GarageConfig struct {
Endpoint string `mapstructure:"endpoint"`
Region string `mapstructure:"region"`
UseSSL bool `mapstructure:"use_ssl"`
ForcePathStyle bool `mapstructure:"force_path_style"`
AdminEndpoint string `mapstructure:"admin_endpoint"`
AdminToken string `mapstructure:"admin_token"`
}
// AuthConfig contains authentication configuration
type AuthConfig struct {
Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
Basic BasicAuthConfig `mapstructure:"basic"`
OIDC OIDCConfig `mapstructure:"oidc"`
}
// BasicAuthConfig contains basic authentication settings
type BasicAuthConfig struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
// OIDCConfig contains OIDC authentication settings
type OIDCConfig struct {
Enabled bool `mapstructure:"enabled"`
ProviderName string `mapstructure:"provider_name"`
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
Scopes []string `mapstructure:"scopes"`
IssuerURL string `mapstructure:"issuer_url"`
AuthURL string `mapstructure:"auth_url"`
TokenURL string `mapstructure:"token_url"`
UserinfoURL string `mapstructure:"userinfo_url"`
SkipIssuerCheck bool `mapstructure:"skip_issuer_check"`
SkipExpiryCheck bool `mapstructure:"skip_expiry_check"`
EmailAttribute string `mapstructure:"email_attribute"`
UsernameAttribute string `mapstructure:"username_attribute"`
NameAttribute string `mapstructure:"name_attribute"`
RoleAttributePath string `mapstructure:"role_attribute_path"`
AdminRole string `mapstructure:"admin_role"`
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
SessionMaxAge int `mapstructure:"session_max_age"`
CookieName string `mapstructure:"cookie_name"`
CookieSecure bool `mapstructure:"cookie_secure"`
CookieHTTPOnly bool `mapstructure:"cookie_http_only"`
CookieSameSite string `mapstructure:"cookie_same_site"`
}
// CORSConfig contains CORS settings for frontend communication
type CORSConfig struct {
Enabled bool `mapstructure:"enabled"`
AllowedOrigins []string `mapstructure:"allowed_origins"`
AllowedMethods []string `mapstructure:"allowed_methods"`
AllowedHeaders []string `mapstructure:"allowed_headers"`
AllowCredentials bool `mapstructure:"allow_credentials"`
MaxAge int `mapstructure:"max_age"`
}
// LoggingConfig contains logging configuration
type LoggingConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
}
// Load reads the configuration from the specified file
func Load(configPath string) (*Config, error) {
// Set default config file name if not specified
if configPath == "" {
configPath = "config.yaml"
}
// Configure viper to read the config file
viper.SetConfigFile(configPath)
viper.SetConfigType("yaml")
// Allow environment variables to override config values
// Environment variables take precedence over config file
viper.AutomaticEnv()
viper.SetEnvPrefix("GARAGE_UI")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// Bind environment variables to config keys
// This ensures env vars override config file values
bindEnvVars()
// Read the config file (optional - will use defaults and env vars if not found)
if _, err := os.Stat(configPath); err == nil {
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("error reading config file: %w", err)
}
}
// Unmarshal the config into the Config struct
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("error unmarshaling config: %w", err)
}
// Validate the configuration
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return &cfg, nil
}
// bindEnvVars binds all environment variables to their corresponding config keys
func bindEnvVars() {
// Server config
viper.BindEnv("server.host", "GARAGE_UI_SERVER_HOST")
viper.BindEnv("server.port", "GARAGE_UI_SERVER_PORT")
viper.BindEnv("server.environment", "GARAGE_UI_SERVER_ENVIRONMENT")
viper.BindEnv("server.frontend_path", "GARAGE_UI_SERVER_FRONTEND_PATH")
// Garage config
viper.BindEnv("garage.endpoint", "GARAGE_UI_GARAGE_ENDPOINT")
viper.BindEnv("garage.region", "GARAGE_UI_GARAGE_REGION")
viper.BindEnv("garage.use_ssl", "GARAGE_UI_GARAGE_USE_SSL")
viper.BindEnv("garage.force_path_style", "GARAGE_UI_GARAGE_FORCE_PATH_STYLE")
viper.BindEnv("garage.admin_endpoint", "GARAGE_UI_GARAGE_ADMIN_ENDPOINT")
viper.BindEnv("garage.admin_token", "GARAGE_UI_GARAGE_ADMIN_TOKEN")
// Auth config
viper.BindEnv("auth.mode", "GARAGE_UI_AUTH_MODE")
viper.BindEnv("auth.basic.username", "GARAGE_UI_AUTH_BASIC_USERNAME")
viper.BindEnv("auth.basic.password", "GARAGE_UI_AUTH_BASIC_PASSWORD")
// OIDC config
viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED")
viper.BindEnv("auth.oidc.provider_name", "GARAGE_UI_AUTH_OIDC_PROVIDER_NAME")
viper.BindEnv("auth.oidc.client_id", "GARAGE_UI_AUTH_OIDC_CLIENT_ID")
viper.BindEnv("auth.oidc.client_secret", "GARAGE_UI_AUTH_OIDC_CLIENT_SECRET")
viper.BindEnv("auth.oidc.scopes", "GARAGE_UI_AUTH_OIDC_SCOPES")
viper.BindEnv("auth.oidc.issuer_url", "GARAGE_UI_AUTH_OIDC_ISSUER_URL")
viper.BindEnv("auth.oidc.auth_url", "GARAGE_UI_AUTH_OIDC_AUTH_URL")
viper.BindEnv("auth.oidc.token_url", "GARAGE_UI_AUTH_OIDC_TOKEN_URL")
viper.BindEnv("auth.oidc.userinfo_url", "GARAGE_UI_AUTH_OIDC_USERINFO_URL")
viper.BindEnv("auth.oidc.skip_issuer_check", "GARAGE_UI_AUTH_OIDC_SKIP_ISSUER_CHECK")
viper.BindEnv("auth.oidc.skip_expiry_check", "GARAGE_UI_AUTH_OIDC_SKIP_EXPIRY_CHECK")
viper.BindEnv("auth.oidc.email_attribute", "GARAGE_UI_AUTH_OIDC_EMAIL_ATTRIBUTE")
viper.BindEnv("auth.oidc.username_attribute", "GARAGE_UI_AUTH_OIDC_USERNAME_ATTRIBUTE")
viper.BindEnv("auth.oidc.name_attribute", "GARAGE_UI_AUTH_OIDC_NAME_ATTRIBUTE")
viper.BindEnv("auth.oidc.role_attribute_path", "GARAGE_UI_AUTH_OIDC_ROLE_ATTRIBUTE_PATH")
viper.BindEnv("auth.oidc.admin_role", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLE")
viper.BindEnv("auth.oidc.tls_skip_verify", "GARAGE_UI_AUTH_OIDC_TLS_SKIP_VERIFY")
viper.BindEnv("auth.oidc.session_max_age", "GARAGE_UI_AUTH_OIDC_SESSION_MAX_AGE")
viper.BindEnv("auth.oidc.cookie_name", "GARAGE_UI_AUTH_OIDC_COOKIE_NAME")
viper.BindEnv("auth.oidc.cookie_secure", "GARAGE_UI_AUTH_OIDC_COOKIE_SECURE")
viper.BindEnv("auth.oidc.cookie_http_only", "GARAGE_UI_AUTH_OIDC_COOKIE_HTTP_ONLY")
viper.BindEnv("auth.oidc.cookie_same_site", "GARAGE_UI_AUTH_OIDC_COOKIE_SAME_SITE")
// CORS config
viper.BindEnv("cors.enabled", "GARAGE_UI_CORS_ENABLED")
viper.BindEnv("cors.allowed_origins", "GARAGE_UI_CORS_ALLOWED_ORIGINS")
viper.BindEnv("cors.allowed_methods", "GARAGE_UI_CORS_ALLOWED_METHODS")
viper.BindEnv("cors.allowed_headers", "GARAGE_UI_CORS_ALLOWED_HEADERS")
viper.BindEnv("cors.allow_credentials", "GARAGE_UI_CORS_ALLOW_CREDENTIALS")
viper.BindEnv("cors.max_age", "GARAGE_UI_CORS_MAX_AGE")
// Logging config
viper.BindEnv("logging.level", "GARAGE_UI_LOGGING_LEVEL")
viper.BindEnv("logging.format", "GARAGE_UI_LOGGING_FORMAT")
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
// Validate server config
if c.Server.Port <= 0 || c.Server.Port > 65535 {
return fmt.Errorf("invalid server port: %d", c.Server.Port)
}
// Validate Garage config
if c.Garage.Endpoint == "" {
return fmt.Errorf("garage endpoint is required")
}
if c.Garage.AdminEndpoint == "" {
return fmt.Errorf("garage admin_endpoint is required")
}
if c.Garage.AdminToken == "" {
return fmt.Errorf("garage admin_token is required")
}
// Validate auth mode
if c.Auth.Mode != "none" && c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" {
return fmt.Errorf("auth mode must be 'none', 'basic', or 'oidc', got: %s", c.Auth.Mode)
}
// Validate basic auth if enabled
if c.Auth.Mode == "basic" {
if c.Auth.Basic.Username == "" || c.Auth.Basic.Password == "" {
return fmt.Errorf("basic auth username and password are required when auth mode is 'basic'")
}
}
// Validate OIDC config if enabled
if c.Auth.Mode == "oidc" {
if c.Auth.OIDC.ClientID == "" {
return fmt.Errorf("oidc client_id is required when auth mode is 'oidc'")
}
if c.Auth.OIDC.IssuerURL == "" {
return fmt.Errorf("oidc issuer_url is required when auth mode is 'oidc'")
}
if len(c.Auth.OIDC.Scopes) == 0 {
return fmt.Errorf("oidc scopes are required when auth mode is 'oidc'")
}
}
return nil
}
// GetAddress returns the full server address (host:port)
func (c *Config) GetAddress() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
}
// IsDevelopment returns true if running in development mode
func (c *Config) IsDevelopment() bool {
return c.Server.Environment == "development"
}
// IsProduction returns true if running in production mode
func (c *Config) IsProduction() bool {
return c.Server.Environment == "production"
}
-303
View File
@@ -1,303 +0,0 @@
package handlers
import (
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// BucketHandler handles bucket-related operations
type BucketHandler struct {
adminService *services.GarageAdminService
s3Service *services.S3Service
}
// NewBucketHandler creates a new bucket handler
func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler {
return &BucketHandler{
adminService: adminService,
s3Service: s3Service,
}
}
// ListBuckets lists all buckets
//
// @Summary List all buckets
// @Description Retrieves a list of all buckets in the Garage storage system with object count and size
// @Tags Buckets
// @Accept json
// @Produce json
// @Success 200 {object} models.APIResponse{data=models.BucketListResponse} "Successfully retrieved list of buckets"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list buckets"
// @Router /api/v1/buckets [get]
func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
ctx := c.Context()
// List all buckets from Garage Admin API
adminBuckets, err := h.adminService.ListBuckets(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()),
)
}
// Convert admin bucket response to BucketInfo
buckets := make([]models.BucketInfo, 0, len(adminBuckets))
for _, adminBucket := range adminBuckets {
// Get the bucket name from global aliases
var bucketName string
if len(adminBucket.GlobalAliases) > 0 {
bucketName = adminBucket.GlobalAliases[0]
} else {
// Skip buckets without global aliases
continue
}
bucketInfo := models.BucketInfo{
Name: bucketName,
CreationDate: adminBucket.Created,
Region: "", // Garage doesn't have regions
}
// Try to get bucket statistics (object count and size)
// This is done asynchronously to avoid blocking the response
// If it fails, we still return the bucket info without stats
stats, err := h.s3Service.GetBucketStatistics(ctx, bucketName)
if err == nil && stats != nil {
bucketInfo.ObjectCount = &stats.ObjectCount
bucketInfo.Size = &stats.TotalSize
}
buckets = append(buckets, bucketInfo)
}
response := models.BucketListResponse{
Buckets: buckets,
Count: len(buckets),
}
return c.JSON(models.SuccessResponse(response))
}
// CreateBucket creates a new bucket
//
// @Summary Create a new bucket
// @Description Creates a new bucket in the Garage storage system
// @Tags Buckets
// @Accept json
// @Produce json
// @Param payload body models.CreateBucketRequest true "Bucket creation payload"
// @Success 201 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket created successfully"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body or bucket name is required"
// @Failure 409 {object} models.APIResponse{error=models.APIError} "Bucket already exists"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create bucket"
// @Router /api/v1/buckets [post]
func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
ctx := c.Context()
// Parse request body
var req models.CreateBucketRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
// Validate bucket name
if req.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Create the bucket
createBucketReq := models.CreateBucketAdminRequest{
GlobalAlias: &req.Name,
}
if _, err := h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()),
)
}
// Return success response
response := map[string]interface{}{
"bucket": req.Name,
"message": "Bucket created successfully",
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(response))
}
// DeleteBucket deletes a bucket
//
// @Summary Delete a bucket
// @Description Deletes an existing bucket from the Garage storage system. The bucket must be empty before deletion.
// @Tags Buckets
// @Accept json
// @Produce json
// @Param name path string true "Name of the bucket to delete"
// @Success 200 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket deleted successfully"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name is required"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket does not exist"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete bucket"
// @Router /api/v1/buckets/{name} [delete]
func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("name")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Check if bucket already exists
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if bucketInfo == nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
)
}
// Delete the bucket
if err := h.adminService.DeleteBucket(ctx, bucketInfo.ID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete bucket: "+err.Error()),
)
}
// Return success response
response := map[string]interface{}{
"bucket": bucketName,
"message": "Bucket deleted successfully",
}
return c.JSON(models.SuccessResponse(response))
}
// GetBucketInfo returns information about a specific bucket
//
// @Summary Get bucket information
// @Description Retrieves detailed information about a specific bucket including creation date and region
// @Tags Buckets
// @Accept json
// @Produce json
// @Param name path string true "Name of the bucket to retrieve information for"
// @Success 200 {object} models.APIResponse{data=models.BucketInfo} "Successfully retrieved bucket information"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name is required"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket does not exist"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve bucket information"
// @Router /api/v1/buckets/{name} [get]
func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("name")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Check if bucket already exists
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if bucketInfo == nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
)
}
return c.JSON(models.SuccessResponse(bucketInfo))
}
// GrantBucketPermission grants permissions for an access key on a bucket
//
// @Summary Grant bucket permissions
// @Description Grants read/write/owner permissions for an access key on a specific bucket
// @Tags Buckets
// @Accept json
// @Produce json
// @Param name path string true "Name of the bucket"
// @Param request body models.GrantBucketPermissionRequest true "Permission grant request"
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Permissions granted successfully"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to grant permissions"
// @Router /api/v1/buckets/{name}/permissions [post]
func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("name")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Parse request body
var req models.GrantBucketPermissionRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
// Validate access key ID
if req.AccessKeyID == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key ID is required"),
)
}
// Get bucket info to retrieve bucket ID
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
)
}
if bucketInfo == nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
)
}
// Build the permission request for Garage Admin API
permRequest := models.BucketKeyPermRequest{
BucketID: bucketInfo.ID,
AccessKeyID: req.AccessKeyID,
Permissions: models.BucketKeyPermission{
Read: req.Permissions.Read,
Write: req.Permissions.Write,
Owner: req.Permissions.Owner,
},
}
// Grant permissions using Garage Admin API
result, err := h.adminService.AllowBucketKey(ctx, permRequest)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(result))
}
-145
View File
@@ -1,145 +0,0 @@
package handlers
import (
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// ClusterHandler handles cluster management operations
type ClusterHandler struct {
adminService *services.GarageAdminService
}
// NewClusterHandler creates a new cluster handler
func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandler {
return &ClusterHandler{
adminService: adminService,
}
}
// GetHealth returns the health status of the cluster
//
// @Summary Get cluster health
// @Description Retrieves the overall health status of the Garage storage cluster
// @Tags Cluster
// @Accept json
// @Produce json
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster health"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster health"
// @Router /api/v1/cluster/health [get]
func (h *ClusterHandler) GetHealth(c fiber.Ctx) error {
ctx := c.Context()
health, err := h.adminService.GetClusterHealth(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster health: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(health))
}
// GetStatus returns the status of the cluster
//
// @Summary Get cluster status
// @Description Retrieves the current status of the Garage storage cluster
// @Tags Cluster
// @Accept json
// @Produce json
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster status"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster status"
// @Router /api/v1/cluster/status [get]
func (h *ClusterHandler) GetStatus(c fiber.Ctx) error {
ctx := c.Context()
status, err := h.adminService.GetClusterStatus(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster status: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(status))
}
// GetStatistics returns global cluster statistics
// GET /api/v1/cluster/statistics
func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error {
ctx := c.Context()
stats, err := h.adminService.GetClusterStatistics(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster statistics: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(stats))
}
// GetNodeInfo returns information about a specific node
//
// @Summary Get node information
// @Description Retrieves detailed information about a specific node in the Garage storage cluster
// @Tags Cluster
// @Accept json
// @Produce json
// @Param node_id path string true "ID of the node to retrieve information for"
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node information"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node information"
// @Router /api/v1/cluster/nodes/{node_id} [get]
func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error {
ctx := c.Context()
nodeID := c.Params("node_id")
if nodeID == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
)
}
info, err := h.adminService.GetNodeInfo(ctx, nodeID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node info: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(info))
}
// GetNodeStatistics returns statistics for a specific node
//
// @Summary Get node statistics
// @Description Retrieves performance statistics and metrics for a specific node in the Garage storage cluster
// @Tags Cluster
// @Accept json
// @Produce json
// @Param node_id path string true "ID of the node to retrieve statistics for"
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node statistics"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node statistics"
// @Router /api/v1/cluster/nodes/{node_id}/statistics [get]
func (h *ClusterHandler) GetNodeStatistics(c fiber.Ctx) error {
ctx := c.Context()
nodeID := c.Params("node_id")
if nodeID == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
)
}
stats, err := h.adminService.GetNodeStatistics(ctx, nodeID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node statistics: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(stats))
}
-40
View File
@@ -1,40 +0,0 @@
package handlers
import (
"time"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// HealthHandler handles health check requests
type HealthHandler struct {
version string
}
// NewHealthHandler creates a new health check handler
func NewHealthHandler(version string) *HealthHandler {
return &HealthHandler{
version: version,
}
}
// Check returns the health status of the service
//
// @Summary Health check
// @Description Returns the health status of the API service along with version information
// @Tags Health
// @Accept json
// @Produce json
// @Success 200 {object} models.APIResponse{data=models.HealthResponse} "Service is healthy"
// @Router /api/v1/health [get]
func (h *HealthHandler) Check(c fiber.Ctx) error {
response := models.HealthResponse{
Status: "healthy",
Timestamp: time.Now(),
Version: h.version,
}
return c.JSON(models.SuccessResponse(response))
}
-145
View File
@@ -1,145 +0,0 @@
package handlers
import (
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// MonitoringHandler handles monitoring operations
type MonitoringHandler struct {
adminService *services.GarageAdminService
s3Service *services.S3Service
}
// NewMonitoringHandler creates a new monitoring handler
func NewMonitoringHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *MonitoringHandler {
return &MonitoringHandler{
adminService: adminService,
s3Service: s3Service,
}
}
// GetMetrics retrieves system metrics from the Admin API
//
// @Summary Get system metrics
// @Description Retrieves system metrics from the Garage Admin API for monitoring purposes
// @Tags Monitoring
// @Accept json
// @Produce text/plain
// @Success 200 {string} string "System metrics in plain text format"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve metrics"
// @Router /api/v1/monitoring/metrics [get]
func (h *MonitoringHandler) GetMetrics(c fiber.Ctx) error {
ctx := c.Context()
metrics, err := h.adminService.GetMetrics(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get metrics: "+err.Error()),
)
}
// Return metrics as plain text
c.Set("Content-Type", "text/plain; charset=utf-8")
return c.SendString(metrics)
}
// CheckAdminHealth checks if the Admin API is reachable
//
// @Summary Check Admin API health
// @Description Performs a health check on the Garage Admin API to verify connectivity and availability
// @Tags Monitoring
// @Accept json
// @Produce json
// @Success 200 {object} models.APIResponse{data=object{status=string,message=string}} "Admin API is healthy"
// @Failure 503 {object} models.APIResponse{error=models.APIError} "Admin API health check failed"
// @Router /api/v1/monitoring/admin-health [get]
func (h *MonitoringHandler) CheckAdminHealth(c fiber.Ctx) error {
ctx := c.Context()
err := h.adminService.HealthCheck(ctx)
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Admin API health check failed: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(map[string]interface{}{
"status": "healthy",
"message": "Admin API is reachable",
}))
}
// GetDashboardMetrics retrieves aggregated dashboard metrics
//
// @Summary Get dashboard metrics
// @Description Retrieves aggregated metrics for the dashboard including storage, buckets, and request metrics
// @Tags Monitoring
// @Accept json
// @Produce json
// @Success 200 {object} models.APIResponse{data=models.DashboardMetrics} "Successfully retrieved dashboard metrics"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get dashboard metrics"
// @Router /api/v1/monitoring/dashboard [get]
func (h *MonitoringHandler) GetDashboardMetrics(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket list
buckets, err := h.adminService.ListBuckets(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get buckets: "+err.Error()),
)
}
// Calculate aggregated metrics
var totalSize int64
var totalObjects int64
usageByBucket := make([]models.BucketUsage, 0)
for _, bucket := range buckets {
// Get bucket info to calculate size and object count
bucketInfo, err := h.adminService.GetBucketInfo(ctx, bucket.ID)
if err != nil {
continue // Skip buckets we can't access
}
// Get size and object count from bucket info
bucketSize := bucketInfo.Bytes
objectCount := bucketInfo.Objects
totalSize += bucketSize
totalObjects += objectCount
// Get bucket name from aliases
bucketName := bucket.ID
if len(bucket.LocalAliases) > 0 {
bucketName = bucket.LocalAliases[0].Alias
} else if len(bucket.GlobalAliases) > 0 {
bucketName = bucket.GlobalAliases[0]
}
usageByBucket = append(usageByBucket, models.BucketUsage{
BucketName: bucketName,
Size: bucketSize,
ObjectCount: objectCount,
})
}
// Calculate percentages
for i := range usageByBucket {
if totalSize > 0 {
usageByBucket[i].Percentage = float64(usageByBucket[i].Size) / float64(totalSize) * 100
}
}
dashboardMetrics := models.DashboardMetrics{
TotalSize: totalSize,
ObjectCount: totalObjects,
BucketCount: len(buckets),
UsageByBucket: usageByBucket,
}
return c.JSON(models.SuccessResponse(dashboardMetrics))
}
-541
View File
@@ -1,541 +0,0 @@
package handlers
import (
"io"
"strconv"
"time"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// ObjectHandler handles object-related operations
type ObjectHandler struct {
s3Service *services.S3Service
}
// NewObjectHandler creates a new object handler
func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler {
return &ObjectHandler{
s3Service: s3Service,
}
}
// ListObjects lists objects in a bucket with optional filtering and pagination
//
// @Summary List objects in a bucket
// @Description Retrieves a list of objects and prefixes (folders) stored in the specified bucket, with optional filtering by prefix, pagination support, and max keys
// @Tags Objects
// @Accept json
// @Produce json
// @Param bucket path string true "Name of the bucket to list objects from"
// @Param prefix query string false "Filter objects by prefix"
// @Param max_keys query int false "Maximum number of objects to return (default: 100)"
// @Param continuation_token query string false "Token for pagination to retrieve next page of results"
// @Success 200 {object} models.APIResponse{data=models.ObjectListResponse} "Successfully retrieved list of objects and prefixes"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list objects"
// @Router /api/v1/buckets/{bucket}/objects [get]
func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("bucket")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Get query parameters for filtering and pagination
prefix := c.Query("prefix", "")
continuationToken := c.Query("continuation_token", "")
maxKeysStr := c.Query("max_keys", "100")
maxKeys, err := strconv.Atoi(maxKeysStr)
if err != nil || maxKeys <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid max_keys parameter"),
)
}
// List objects in the bucket
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys, continuationToken)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(objects))
}
// UploadObject uploads an object to a bucket
//
// @Summary Upload object to bucket
// @Description Uploads an object to the specified bucket using multipart/form-data
// @Tags Objects
// @Accept multipart/form-data
// @Produce json
// @Param bucket path string true "Name of the bucket to upload the object to"
// @Param file formData file true "File to upload"
// @Param key formData string false "Object key (path in bucket). If not provided, the filename will be used"
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object"
// @Router /api/v1/buckets/{bucket}/objects [post]
func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("bucket")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Get file from multipart form
file, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "File is required: "+err.Error()),
)
}
// Get object key (path in bucket)
key := c.FormValue("key")
if key == "" {
// Use filename as key if not provided
key = file.Filename
}
// Open the uploaded file
fileHandle, err := file.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open uploaded file: "+err.Error()),
)
}
defer fileHandle.Close()
// Get content type
contentType := file.Header.Get("Content-Type")
// Upload to Garage
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, fileHandle, contentType)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
)
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
}
// GetObject retrieves an object from a bucket
//
// @Summary Get object from bucket
// @Description Retrieves an object stored in the specified bucket
// @Tags Objects
// @Accept json
// @Produce application/octet-stream
// @Param bucket path string true "Name of the bucket containing the object"
// @Param key path string true "Key (path) of the object"
// @Param download query bool false "Set to true to download the object as an attachment"
// @Success 200 {file} binary "Successfully retrieved the object"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
// @Router /api/v1/buckets/{bucket}/objects/{key} [get]
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Get object from Garage
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
defer body.Close()
// Set response headers
c.Set("Content-Type", objectInfo.ContentType)
c.Set("Content-Length", string(rune(objectInfo.Size)))
c.Set("ETag", objectInfo.ETag)
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
// Check if client wants to download or view inline
if c.Query("download") == "true" {
c.Set("Content-Disposition", "attachment; filename=\""+key+"\"")
}
// Stream the object body to the client
return c.SendStream(body)
}
// DeleteObject deletes an object from a bucket
//
// @Summary Delete object from bucket
// @Description Deletes an object stored in the specified bucket
// @Tags Objects
// @Accept json
// @Produce json
// @Param bucket path string true "Name of the bucket containing the object"
// @Param key path string true "Key (path) of the object"
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteResponse} "Successfully deleted the object"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete object"
// @Router /api/v1/buckets/{bucket}/objects/{key} [delete]
func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Check if object exists
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"),
)
}
// Delete the object
if err := h.s3Service.DeleteObject(ctx, bucketName, key); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete object: "+err.Error()),
)
}
// Return success response
response := models.ObjectDeleteResponse{
Bucket: bucketName,
Key: key,
Deleted: true,
}
return c.JSON(models.SuccessResponse(response))
}
// GetObjectMetadata returns metadata for an object without downloading it
//
// @Summary Get object metadata
// @Description Retrieves metadata information about an object without downloading the actual content
// @Tags Objects
// @Accept json
// @Produce json
// @Param bucket path string true "Name of the bucket containing the object"
// @Param key path string true "Key (path) of the object"
// @Success 200 {object} models.APIResponse{data=models.ObjectInfo} "Successfully retrieved object metadata"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
// @Router /api/v1/buckets/{bucket}/objects/{key}/metadata [get]
func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Get object metadata
metadata, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(metadata))
}
// GetPresignedURL generates a pre-signed URL for accessing an object
//
// @Summary Get pre-signed URL for object
// @Description Generates a pre-signed URL that allows temporary access to the specified object
// @Tags Objects
// @Accept json
// @Produce json
// @Param bucket path string true "Name of the bucket containing the object"
// @Param key path string true "Key (path) of the object"
// @Param expires_in query int false "Expiration time in seconds for the pre-signed URL (default: 3600 seconds)"
// @Success 200 {object} models.APIResponse{data=models.PresignedURLResponse} "Successfully generated pre-signed URL"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to generate pre-signed URL"
// @Router /api/v1/buckets/{bucket}/objects/{key}/presigned-url [get]
func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Get expiration time from query parameter (default: 1 hour)
expiresInStr := c.Query("expires_in", "3600")
expiresIn, err := strconv.ParseInt(expiresInStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time: "+err.Error()),
)
}
// Validate expiration time (1 second to 7 days)
if expiresIn <= 0 || expiresIn > 604800 { // Max 7 days
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time (must be between 1 and 604800 seconds)"),
)
}
// Check if object exists
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"),
)
}
// Generate pre-signed URL
url, err := h.s3Service.GetPresignedURL(ctx, bucketName, key, time.Duration(expiresIn)*time.Second)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to generate pre-signed URL: "+err.Error()),
)
}
response := models.PresignedURLResponse{
URL: url,
ExpiresIn: expiresIn,
Bucket: bucketName,
Key: key,
}
return c.JSON(models.SuccessResponse(response))
}
// DeleteMultipleObjects deletes multiple objects from a bucket
//
// @Summary Delete multiple objects from bucket
// @Description Deletes multiple objects stored in the specified bucket
// @Tags Objects
// @Accept json
// @Produce json
// @Param bucket path string true "Name of the bucket containing the objects"
// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context"
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete objects"
// @Router /api/v1/buckets/{bucket}/objects/delete-multiple [post]
func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("bucket")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Parse request body to get keys and optional prefix
var req struct {
Keys []string `json:"keys"`
Prefix string `json:"prefix,omitempty"`
}
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
if len(req.Keys) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key is required"),
)
}
// Delete multiple objects
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
)
}
response := models.ObjectDeleteMultipleResponse{
Bucket: bucketName,
Deleted: len(req.Keys),
Keys: req.Keys,
}
return c.JSON(models.SuccessResponse(response))
}
// UploadMultipleObjects uploads multiple objects to a bucket
//
// @Summary Upload multiple objects to bucket
// @Description Uploads multiple objects to the specified bucket using multipart/form-data. Accepts unlimited number of files and handles them in a loop.
// @Tags Objects
// @Accept multipart/form-data
// @Produce json
// @Param bucket path string true "Name of the bucket to upload the objects to"
// @Param files formData file true "Files to upload (can be multiple)"
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadMultipleResponse} "Objects uploaded successfully (including partial failures)"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload objects"
// @Router /api/v1/buckets/{bucket}/objects/upload-multiple [post]
func (h *ObjectHandler) UploadMultipleObjects(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("bucket")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Parse multipart form to get all files
form, err := c.MultipartForm()
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Failed to parse multipart form: "+err.Error()),
)
}
files := form.File["files"]
if len(files) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "At least one file is required"),
)
}
// Prepare upload data structure
uploadFiles := make([]struct {
Key string
Body io.Reader
ContentType string
}, len(files))
// Open all files and prepare for upload
for i, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open file "+fileHeader.Filename+": "+err.Error()),
)
}
defer file.Close()
// Use filename as the key
key := fileHeader.Filename
contentType := fileHeader.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
uploadFiles[i] = struct {
Key string
Body io.Reader
ContentType string
}{
Key: key,
Body: file,
ContentType: contentType,
}
}
// Upload all files using the service method
results := h.s3Service.UploadMultipleObjects(ctx, bucketName, uploadFiles)
// Process results and categorize successes and failures
var successFiles []models.ObjectUploadResult
var failedFiles []models.ObjectUploadFailedResult
successCount := 0
failureCount := 0
for _, result := range results {
if result.Success {
successCount++
successFiles = append(successFiles, models.ObjectUploadResult{
Key: result.Key,
ETag: result.ETag,
Size: result.Size,
ContentType: result.ContentType,
})
} else {
failureCount++
failedFiles = append(failedFiles, models.ObjectUploadFailedResult{
Key: result.Key,
Error: result.Error.Error(),
ContentType: result.ContentType,
})
}
}
response := models.ObjectUploadMultipleResponse{
Bucket: bucketName,
TotalFiles: len(files),
SuccessCount: successCount,
FailureCount: failureCount,
SuccessFiles: successFiles,
FailedFiles: failedFiles,
}
// Return 201 if all succeeded, 207 (Multi-Status) if partial success, 500 if all failed
statusCode := fiber.StatusCreated
if failureCount > 0 && successCount > 0 {
statusCode = fiber.StatusMultiStatus // 207
} else if failureCount > 0 && successCount == 0 {
statusCode = fiber.StatusInternalServerError
}
return c.Status(statusCode).JSON(models.SuccessResponse(response))
}
-376
View File
@@ -1,376 +0,0 @@
package handlers
import (
"time"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// UserHandler handles user/key management operations using Garage Admin API
type UserHandler struct {
adminService *services.GarageAdminService
}
// NewUserHandler creates a new user handler
func NewUserHandler(adminService *services.GarageAdminService) *UserHandler {
return &UserHandler{
adminService: adminService,
}
}
// ListUsers lists all users/access keys
//
// @Summary List all users
// @Description Retrieves a list of all users/access keys
// @Tags Users
// @Produce json
// @Success 200 {object} models.APIResponse{data=models.UserListResponse} "List of users retrieved successfully"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list users"
// @Router /api/v1/users [get]
func (h *UserHandler) ListUsers(c fiber.Ctx) error {
ctx := c.Context()
keys, err := h.adminService.ListKeys(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to list users: "+err.Error()),
)
}
// Convert to UserInfo format
users := make([]models.UserInfo, 0, len(keys))
for _, key := range keys {
// Get full key info to retrieve bucket permissions
keyInfo, err := h.adminService.GetKeyInfo(ctx, key.ID, false)
if err != nil {
// If we can't get full info, skip this key or use basic info
continue
}
// Convert bucket permissions to frontend format
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
// Determine status based on expiration
status := "active"
if keyInfo.Expired {
status = "inactive"
}
users = append(users, models.UserInfo{
AccessKeyID: keyInfo.AccessKeyID,
Name: keyInfo.Name,
CreatedAt: keyInfo.Created,
Status: status,
BucketPermissions: bucketPermissions,
Expiration: keyInfo.Expiration,
Expired: keyInfo.Expired,
})
}
return c.JSON(models.SuccessResponse(models.UserListResponse{
Users: users,
Count: len(users),
}))
}
// convertBucketPermissionsToBucketPermissions converts Garage bucket permissions to frontend BucketPermission format
func convertBucketPermissionsToBucketPermissions(buckets []models.KeyBucketInfo) []models.BucketPermission {
permissions := make([]models.BucketPermission, 0, len(buckets))
for _, bucket := range buckets {
// Get bucket name from aliases
var bucketName string
if len(bucket.GlobalAliases) > 0 {
bucketName = bucket.GlobalAliases[0]
} else if len(bucket.LocalAliases) > 0 {
bucketName = bucket.LocalAliases[0]
} else {
bucketName = bucket.ID
}
// Create bucket permission with simple read/write/owner flags
permissions = append(permissions, models.BucketPermission{
BucketID: bucket.ID,
BucketName: bucketName,
Read: bucket.Permissions.Read,
Write: bucket.Permissions.Write,
Owner: bucket.Permissions.Owner,
})
}
return permissions
}
// CreateUser creates a new user/access key
//
// @Summary Create a new user
// @Description Creates a new user/access key with optional name
// @Tags Users
// @Accept json
// @Produce json
// @Param request body models.CreateUserRequest true "User creation request"
// @Success 201 {object} models.APIResponse{data=models.UserInfo} "User created successfully"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create user"
// @Router /api/v1/users [post]
func (h *UserHandler) CreateUser(c fiber.Ctx) error {
ctx := c.Context()
var req models.CreateUserRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
// Prepare create key request
createReq := models.CreateKeyRequest{}
if req.Name != "" {
createReq.Name = &req.Name
}
// Create the key
keyInfo, err := h.adminService.CreateKey(ctx, createReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create user: "+err.Error()),
)
}
// Convert bucket permissions to frontend format
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
// Determine status
status := "active"
if keyInfo.Expired {
status = "inactive"
}
// Convert to UserInfo format
userInfo := models.UserInfo{
AccessKeyID: keyInfo.AccessKeyID,
SecretKey: keyInfo.SecretAccessKey,
Name: keyInfo.Name,
CreatedAt: keyInfo.Created,
Status: status,
BucketPermissions: bucketPermissions,
Expiration: keyInfo.Expiration,
Expired: keyInfo.Expired,
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(userInfo))
}
// DeleteUser deletes a user/access key
//
// @Summary Delete a user
// @Description Deletes a specific user/access key
// @Tags Users
// @Produce json
// @Param access_key path string true "Access key of the user to delete"
// @Success 200 {object} models.APIResponse{data=map[string]interface{}} "User deleted successfully"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete user"
// @Router /api/v1/users/{access_key} [delete]
func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
ctx := c.Context()
accessKey := c.Params("access_key")
if accessKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
)
}
// Delete the key
err := h.adminService.DeleteKey(ctx, accessKey)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to delete user: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(map[string]interface{}{
"access_key": accessKey,
"deleted": true,
}))
}
// GetUser retrieves information about a specific user/access key
//
// @Summary Get user information
// @Description Retrieves information about a specific user/access key
// @Tags Users
// @Produce json
// @Param access_key path string true "Access key of the user to retrieve"
// @Success 200 {object} models.APIResponse{data=models.UserInfo} "User information retrieved successfully"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get user info"
// @Router /api/v1/users/{access_key} [get]
func (h *UserHandler) GetUser(c fiber.Ctx) error {
ctx := c.Context()
accessKey := c.Params("access_key")
if accessKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
)
}
// Get key information (without secret key)
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, false)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get user info: "+err.Error()),
)
}
// Convert bucket permissions to frontend format
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
// Determine status
status := "active"
if keyInfo.Expired {
status = "inactive"
}
// Convert to UserInfo format
userInfo := models.UserInfo{
AccessKeyID: keyInfo.AccessKeyID,
Name: keyInfo.Name,
CreatedAt: keyInfo.Created,
Status: status,
BucketPermissions: bucketPermissions,
Expiration: keyInfo.Expiration,
Expired: keyInfo.Expired,
}
return c.JSON(models.SuccessResponse(userInfo))
}
// GetUserSecretKey retrieves the secret key for a specific user/access key
//
// @Summary Get user secret key
// @Description Retrieves the secret access key for a specific user/access key
// @Tags Users
// @Produce json
// @Param access_key path string true "Access key of the user to retrieve secret for"
// @Success 200 {object} models.APIResponse{data=map[string]string} "Secret key retrieved successfully"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get secret key"
// @Router /api/v1/users/{access_key}/secret [get]
func (h *UserHandler) GetUserSecretKey(c fiber.Ctx) error {
ctx := c.Context()
accessKey := c.Params("access_key")
if accessKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
)
}
// Get key information WITH secret key
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, true)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get secret key: "+err.Error()),
)
}
// Return only the secret key
return c.JSON(models.SuccessResponse(map[string]string{
"secretKey": *keyInfo.SecretAccessKey,
}))
}
// UpdateUserPermissions updates user permissions
//
// @Summary Update user permissions
// @Description Updates the permissions and settings for a specific user/access key
// @Tags Users
// @Accept json
// @Produce json
// @Param access_key path string true "Access key of the user to update"
// @Param request body models.UpdateUserRequest true "User update request with new permissions"
// @Success 200 {object} models.APIResponse{data=models.UserInfo} "User updated successfully"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required or invalid request body"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update user"
// @Router /api/v1/users/{access_key} [patch]
func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
ctx := c.Context()
accessKey := c.Params("access_key")
if accessKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
)
}
var req models.UpdateUserRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
// Prepare update request
updateReq := models.UpdateKeyRequest{}
// Handle status change (activate/deactivate)
if req.Status != nil {
if *req.Status == "inactive" {
// Deactivate by setting expiration to the past
pastTime := time.Now().Add(-24 * time.Hour)
updateReq.Expiration = &pastTime
updateReq.NeverExpires = false
} else if *req.Status == "active" {
// Activate by removing expiration (set to never expire)
updateReq.NeverExpires = true
}
}
// Handle explicit expiration date setting
if req.Expiration != nil && *req.Expiration != "" {
expirationTime, err := time.Parse(time.RFC3339, *req.Expiration)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration date format: "+err.Error()),
)
}
updateReq.Expiration = &expirationTime
updateReq.NeverExpires = false
}
// Update the key
keyInfo, err := h.adminService.UpdateKey(ctx, accessKey, updateReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update user: "+err.Error()),
)
}
// Convert bucket permissions to frontend format
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
// Determine status
status := "active"
if keyInfo.Expired {
status = "inactive"
}
// Convert to UserInfo format
userInfo := models.UserInfo{
AccessKeyID: keyInfo.AccessKeyID,
Name: keyInfo.Name,
CreatedAt: keyInfo.Created,
Status: status,
BucketPermissions: bucketPermissions,
Expiration: keyInfo.Expiration,
Expired: keyInfo.Expired,
}
return c.JSON(models.SuccessResponse(userInfo))
}
-133
View File
@@ -1,133 +0,0 @@
package middleware
import (
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// AuthMiddleware returns a Fiber middleware for authentication
// It handles different auth modes: none, basic, and OIDC
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler {
return func(c fiber.Ctx) error {
// If auth mode is "none", allow all requests
if cfg.Mode == "none" {
return c.Next()
}
// Handle basic authentication
if cfg.Mode == "basic" {
return handleBasicAuth(c, authService)
}
// Handle OIDC authentication
if cfg.Mode == "oidc" {
return handleOIDCAuth(c, authService, &cfg.OIDC)
}
// Unknown auth mode - deny access
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid authentication mode"),
)
}
}
// handleBasicAuth validates basic authentication credentials
func handleBasicAuth(c fiber.Ctx, authService *auth.AuthService) error {
// Get Authorization header
authHeader := c.Get("Authorization")
if authHeader == "" {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Authorization header required"),
)
}
// Parse basic auth credentials
username, password, ok := auth.ParseBasicAuth(authHeader)
if !ok {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid Authorization header format"),
)
}
// Validate credentials
if !authService.ValidateBasicAuth(username, password) {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
)
}
// Store username in context for later use
c.Locals("username", username)
return c.Next()
}
// handleOIDCAuth validates OIDC session/token
func handleOIDCAuth(c fiber.Ctx, authService *auth.AuthService, oidcCfg *config.OIDCConfig) error {
// Get session cookie
sessionCookie := c.Cookies(oidcCfg.CookieName)
if sessionCookie == "" {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"),
)
}
// Validate JWT session token
userInfo, err := authService.ValidateSessionToken(sessionCookie)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid or expired session"),
)
}
// Store user info in context for handlers to use
c.Locals("userInfo", userInfo)
c.Locals("username", userInfo.Username)
c.Locals("email", userInfo.Email)
return c.Next()
}
func RequireAuth(cfg *config.AuthConfig) fiber.Handler {
return func(c fiber.Ctx) error {
if cfg.Mode == "none" {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"),
)
}
return c.Next()
}
}
func RequireAdmin(authService *auth.AuthService) fiber.Handler {
return func(c fiber.Ctx) error {
userInfoInterface := c.Locals("userInfo")
if userInfoInterface == nil {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
)
}
userInfo, ok := userInfoInterface.(*auth.UserInfo)
if !ok {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
)
}
// Check if user has admin role
if !authService.IsAdmin(userInfo) {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin role required"),
)
}
return c.Next()
}
}
-65
View File
@@ -1,65 +0,0 @@
package middleware
import (
"strings"
"Noooste/garage-ui/internal/config"
"github.com/gofiber/fiber/v3"
)
// CORSMiddleware creates a CORS middleware from configuration
func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
// If CORS is disabled, return a no-op middleware
if !cfg.Enabled {
return func(c fiber.Ctx) error {
return c.Next()
}
}
return func(c fiber.Ctx) error {
origin := c.Get("Origin")
// Check if origin is allowed
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins) {
// Set CORS headers
c.Set("Access-Control-Allow-Origin", origin)
if cfg.AllowCredentials {
c.Set("Access-Control-Allow-Credentials", "true")
}
// Set allowed methods
if len(cfg.AllowedMethods) > 0 {
c.Set("Access-Control-Allow-Methods", strings.Join(cfg.AllowedMethods, ", "))
}
// Set allowed headers
if len(cfg.AllowedHeaders) > 0 {
c.Set("Access-Control-Allow-Headers", strings.Join(cfg.AllowedHeaders, ", "))
}
// Set max age for preflight cache
if cfg.MaxAge > 0 {
c.Set("Access-Control-Max-Age", string(rune(cfg.MaxAge)))
}
}
// Handle preflight requests
if c.Method() == "OPTIONS" {
return c.SendStatus(fiber.StatusNoContent)
}
return c.Next()
}
}
// isAllowedOrigin checks if an origin is in the allowed list
func isAllowedOrigin(origin string, allowedOrigins []string) bool {
for _, allowed := range allowedOrigins {
if allowed == "*" || allowed == origin {
return true
}
}
return false
}
-263
View File
@@ -1,263 +0,0 @@
package models
import "time"
// ====================================
// Access Key Models
// ====================================
// GarageKeyInfo represents detailed information about a Garage access key
type GarageKeyInfo struct {
AccessKeyID string `json:"accessKeyId"`
Name string `json:"name"`
Expired bool `json:"expired"`
SecretAccessKey *string `json:"secretAccessKey,omitempty"`
Permissions KeyPermissions `json:"permissions"`
Buckets []KeyBucketInfo `json:"buckets"`
Created *time.Time `json:"created,omitempty"`
Expiration *time.Time `json:"expiration,omitempty"`
}
// KeyPermissions represents permissions for an access key
type KeyPermissions struct {
CreateBucket bool `json:"createBucket"`
}
// KeyBucketInfo represents bucket information associated with a key
type KeyBucketInfo struct {
ID string `json:"id"`
GlobalAliases []string `json:"globalAliases"`
LocalAliases []string `json:"localAliases"`
Permissions BucketKeyPermission `json:"permissions"`
}
// BucketKeyPermission represents permissions a key has on a specific bucket
type BucketKeyPermission struct {
Read bool `json:"read"`
Write bool `json:"write"`
Owner bool `json:"owner"`
}
// CreateKeyRequest represents the request to create a new access key
type CreateKeyRequest struct {
Name *string `json:"name,omitempty"`
Expiration *time.Time `json:"expiration,omitempty"`
NeverExpires bool `json:"neverExpires,omitempty"`
Allow *KeyPermissions `json:"allow,omitempty"`
Deny *KeyPermissions `json:"deny,omitempty"`
}
// UpdateKeyRequest represents the request to update an access key
type UpdateKeyRequest struct {
Name *string `json:"name,omitempty"`
Expiration *time.Time `json:"expiration,omitempty"`
NeverExpires bool `json:"neverExpires,omitempty"`
Allow *KeyPermissions `json:"allow,omitempty"`
Deny *KeyPermissions `json:"deny,omitempty"`
}
// ImportKeyRequest represents the request to import an existing key
type ImportKeyRequest struct {
AccessKeyID string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
Name *string `json:"name,omitempty"`
}
// ListKeysResponseItem represents a single key in the list response
type ListKeysResponseItem struct {
ID string `json:"id"`
Name string `json:"name"`
Expired bool `json:"expired"`
Created *time.Time `json:"created,omitempty"`
Expiration *time.Time `json:"expiration,omitempty"`
}
// ====================================
// Bucket Models (Admin API)
// ====================================
// GarageBucketInfo represents detailed information about a bucket from Admin API
type GarageBucketInfo struct {
ID string `json:"id"`
Created time.Time `json:"created"`
GlobalAliases []string `json:"globalAliases"`
WebsiteAccess bool `json:"websiteAccess"`
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
Keys []BucketKeyInfo `json:"keys"`
Objects int64 `json:"objects"`
Bytes int64 `json:"bytes"`
UnfinishedUploads int64 `json:"unfinishedUploads"`
UnfinishedMultipartUploads int64 `json:"unfinishedMultipartUploads"`
UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"`
UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"`
Quotas *BucketQuotas `json:"quotas,omitempty"`
}
// BucketWebsiteConfig represents website configuration for a bucket
type BucketWebsiteConfig struct {
IndexDocument string `json:"indexDocument"`
ErrorDocument *string `json:"errorDocument,omitempty"`
}
// BucketQuotas represents quota settings for a bucket
type BucketQuotas struct {
MaxSize *int64 `json:"maxSize,omitempty"`
MaxObjects *int64 `json:"maxObjects,omitempty"`
}
// BucketKeyInfo represents key information associated with a bucket
type BucketKeyInfo struct {
AccessKeyID string `json:"accessKeyId"`
Name string `json:"name"`
Permissions BucketKeyPermission `json:"permissions"`
BucketLocalAliases []string `json:"bucketLocalAliases"`
}
// CreateBucketAdminRequest represents the request to create a bucket via Admin API
type CreateBucketAdminRequest struct {
GlobalAlias *string `json:"globalAlias,omitempty"`
LocalAlias *CreateBucketLocalAlias `json:"localAlias,omitempty"`
}
// CreateBucketLocalAlias represents local alias configuration when creating a bucket
type CreateBucketLocalAlias struct {
AccessKeyID string `json:"accessKeyId"`
Alias string `json:"alias"`
Allow *BucketKeyPermission `json:"allow,omitempty"`
}
// UpdateBucketRequest represents the request to update bucket settings
type UpdateBucketRequest struct {
WebsiteAccess *UpdateBucketWebsiteAccess `json:"websiteAccess,omitempty"`
Quotas *BucketQuotas `json:"quotas,omitempty"`
}
// UpdateBucketWebsiteAccess represents website access settings update
type UpdateBucketWebsiteAccess struct {
Enabled bool `json:"enabled"`
IndexDocument *string `json:"indexDocument,omitempty"`
ErrorDocument *string `json:"errorDocument,omitempty"`
}
// ListBucketsResponseItem represents a single bucket in the list response
type ListBucketsResponseItem struct {
ID string `json:"id"`
Created time.Time `json:"created"`
GlobalAliases []string `json:"globalAliases"`
LocalAliases []BucketLocalAlias `json:"localAliases"`
}
// BucketLocalAlias represents a local alias for a bucket
type BucketLocalAlias struct {
AccessKeyID string `json:"accessKeyId"`
Alias string `json:"alias"`
}
// ====================================
// Bucket Alias Models
// ====================================
// AddBucketAliasRequest represents the request to add a bucket alias
type AddBucketAliasRequest struct {
BucketID string `json:"bucketId"`
GlobalAlias *string `json:"globalAlias,omitempty"`
LocalAlias *string `json:"localAlias,omitempty"`
AccessKeyID *string `json:"accessKeyId,omitempty"`
}
// RemoveBucketAliasRequest represents the request to remove a bucket alias
type RemoveBucketAliasRequest struct {
BucketID string `json:"bucketId"`
GlobalAlias *string `json:"globalAlias,omitempty"`
LocalAlias *string `json:"localAlias,omitempty"`
AccessKeyID *string `json:"accessKeyId,omitempty"`
}
// ====================================
// Permission Models
// ====================================
// BucketKeyPermRequest represents a request to change bucket-key permissions
type BucketKeyPermRequest struct {
BucketID string `json:"bucketId"`
AccessKeyID string `json:"accessKeyId"`
Permissions BucketKeyPermission `json:"permissions"`
}
// ====================================
// Cluster Models
// ====================================
// ClusterHealth represents the health status of the cluster
type ClusterHealth struct {
Status string `json:"status"`
KnownNodes int `json:"knownNodes"`
ConnectedNodes int `json:"connectedNodes"`
StorageNodes int `json:"storageNodes"`
StorageNodesUp int `json:"storageNodesOk"`
Partitions int `json:"partitions"`
PartitionsQuorum int `json:"partitionsQuorum"`
PartitionsAllOk int `json:"partitionsAllOk"`
}
// ClusterStatus represents the current status of the cluster
type ClusterStatus struct {
LayoutVersion int `json:"layoutVersion"`
Nodes []NodeInfo `json:"nodes"`
}
// ClusterStatistics represents global cluster statistics
type ClusterStatistics struct {
Freeform string `json:"freeform"`
}
// ====================================
// Node Models
// ====================================
// NodeInfo represents information about a cluster node
type NodeInfo struct {
ID string `json:"id"`
IsUp bool `json:"isUp"`
LastSeenSecsAgo *int64 `json:"lastSeenSecsAgo,omitempty"`
Hostname *string `json:"hostname,omitempty"`
Addr *string `json:"addr,omitempty"`
GarageVersion *string `json:"garageVersion,omitempty"`
Role *NodeRole `json:"role,omitempty"`
Draining bool `json:"draining"`
DataPartition *FreeSpaceInfo `json:"dataPartition,omitempty"`
MetadataPartition *FreeSpaceInfo `json:"metadataPartition,omitempty"`
}
// NodeRole represents the role assigned to a node
type NodeRole struct {
Zone string `json:"zone"`
Capacity *int64 `json:"capacity,omitempty"`
Tags []string `json:"tags"`
}
// FreeSpaceInfo represents disk space information
type FreeSpaceInfo struct {
Available int64 `json:"available"`
Total int64 `json:"total"`
}
// NodeInfoResponse represents the response for GetNodeInfo
type NodeInfoResponse struct {
NodeID string `json:"nodeId"`
GarageVersion string `json:"garageVersion"`
RustVersion string `json:"rustVersion"`
DBEngine string `json:"dbEngine"`
GarageFeatures []string `json:"garageFeatures,omitempty"`
}
// NodeStatisticsResponse represents the response for GetNodeStatistics
type NodeStatisticsResponse struct {
Freeform string `json:"freeform"`
}
// MultiNodeResponse represents responses from multiple nodes
type MultiNodeResponse struct {
Success map[string]interface{} `json:"success"`
Error map[string]string `json:"error"`
}
-61
View File
@@ -1,61 +0,0 @@
package models
// CreateBucketRequest represents a request to create a new bucket
type CreateBucketRequest struct {
Name string `json:"name" validate:"required"`
Region string `json:"region,omitempty"`
}
// GrantBucketPermissionRequest represents a request to grant permissions on a bucket
type GrantBucketPermissionRequest struct {
AccessKeyID string `json:"accessKeyId" validate:"required"`
Permissions BucketKeyPermission `json:"permissions" validate:"required"`
}
// DeleteBucketRequest represents a request to delete a bucket
type DeleteBucketRequest struct {
Name string `json:"name" validate:"required"`
}
// ListObjectsRequest represents a request to list objects in a bucket
type ListObjectsRequest struct {
Bucket string `json:"bucket" validate:"required"`
Prefix string `json:"prefix,omitempty"`
MaxKeys int `json:"max_keys,omitempty"`
Marker string `json:"marker,omitempty"`
}
// UploadObjectRequest represents metadata for an object upload
type UploadObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
ContentType string `json:"content_type,omitempty"`
}
// DeleteObjectRequest represents a request to delete an object
type DeleteObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
}
// GetObjectRequest represents a request to get/download an object
type GetObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
}
// CreateUserRequest represents a request to create a new user/key
type CreateUserRequest struct {
Name string `json:"name,omitempty"`
}
// DeleteUserRequest represents a request to delete a user/key
type DeleteUserRequest struct {
AccessKey string `json:"access_key" validate:"required"`
}
// UpdateUserRequest represents a request to update user permissions
type UpdateUserRequest struct {
Status *string `json:"status,omitempty"` // "active" or "inactive"
Expiration *string `json:"expiration,omitempty"` // ISO 8601 date string
}
-204
View File
@@ -1,204 +0,0 @@
package models
import "time"
// DashboardMetrics represents aggregated metrics for the dashboard
type DashboardMetrics struct {
TotalSize int64 `json:"totalSize"`
ObjectCount int64 `json:"objectCount"`
BucketCount int `json:"bucketCount"`
UsageByBucket []BucketUsage `json:"usageByBucket"`
}
// BucketUsage represents storage usage for a single bucket
type BucketUsage struct {
BucketName string `json:"bucketName"`
Size int64 `json:"size"`
ObjectCount int64 `json:"objectCount"`
Percentage float64 `json:"percentage"`
}
// APIResponse is the standard response structure for all API endpoints
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
}
// APIError represents an error in the API response
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// HealthResponse represents the health check response
type HealthResponse struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version"`
}
// BucketInfo represents information about a bucket
type BucketInfo struct {
Name string `json:"name"`
CreationDate time.Time `json:"creation_date"`
ObjectCount *int64 `json:"object_count,omitempty"`
Size *int64 `json:"size,omitempty"`
Region string `json:"region,omitempty"`
}
// BucketListResponse represents a list of buckets
type BucketListResponse struct {
Buckets []BucketInfo `json:"buckets"`
Count int `json:"count"`
}
// ObjectInfo represents information about an object
type ObjectInfo struct {
Key string `json:"key"`
Size int64 `json:"size"`
LastModified time.Time `json:"last_modified"`
ETag string `json:"etag"`
ContentType string `json:"content_type,omitempty"`
StorageClass string `json:"storage_class,omitempty"`
}
// ObjectListResponse represents a list of objects in a bucket
type ObjectListResponse struct {
Bucket string `json:"bucket"`
Objects []ObjectInfo `json:"objects"`
Prefixes []string `json:"prefixes"`
Count int `json:"count"`
IsTruncated bool `json:"is_truncated"`
NextContinuationToken string `json:"next_continuation_token,omitempty"`
}
// ObjectUploadResponse represents the response after uploading an object
type ObjectUploadResponse struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
ETag string `json:"etag"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
}
// ObjectUploadMultipleResponse represents the response after uploading multiple objects
type ObjectUploadMultipleResponse struct {
Bucket string `json:"bucket"`
TotalFiles int `json:"total_files"`
SuccessCount int `json:"success_count"`
FailureCount int `json:"failure_count"`
SuccessFiles []ObjectUploadResult `json:"success_files"`
FailedFiles []ObjectUploadFailedResult `json:"failed_files,omitempty"`
}
// ObjectUploadResult represents a successful upload result
type ObjectUploadResult struct {
Key string `json:"key"`
ETag string `json:"etag"`
Size int64 `json:"size"`
ContentType string `json:"content_type,omitempty"`
}
// ObjectUploadFailedResult represents a failed upload result
type ObjectUploadFailedResult struct {
Key string `json:"key"`
Error string `json:"error"`
ContentType string `json:"content_type,omitempty"`
}
// ObjectDeleteResponse represents the response after deleting an object
type ObjectDeleteResponse struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
Deleted bool `json:"deleted"`
}
// UserInfo represents information about a Garage user (key pair)
type UserInfo struct {
AccessKeyID string `json:"accessKeyId"`
Name string `json:"name"`
SecretKey *string `json:"secretKey,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
LastUsed *time.Time `json:"lastUsed,omitempty"`
Status string `json:"status"` // "active" or "inactive"
BucketPermissions []BucketPermission `json:"permissions"` // Array of bucket permissions
Expiration *time.Time `json:"expiration,omitempty"`
Expired bool `json:"expired"`
}
// BucketPermission represents permissions for a specific bucket
type BucketPermission struct {
BucketID string `json:"bucketId"`
BucketName string `json:"bucketName"`
Read bool `json:"read"`
Write bool `json:"write"`
Owner bool `json:"owner"`
}
// Permission represents a permission entry for access control (legacy/deprecated)
type Permission struct {
Resource string `json:"resource"`
Actions []string `json:"actions"`
Effect string `json:"effect"` // "Allow" or "Deny"
}
type PresignedURLResponse struct {
URL string `json:"url"`
ExpiresIn int64 `json:"expires_in"` // in seconds
Bucket string `json:"bucket"`
Key string `json:"key"`
}
type ObjectDeleteMultipleResponse struct {
Bucket string `json:"bucket"`
Deleted int `json:"deleted"`
Keys []string `json:"keys"`
}
// UserListResponse represents a list of users/keys
type UserListResponse struct {
Users []UserInfo `json:"users"`
Count int `json:"count"`
}
// Helper functions to create standard responses
// SuccessResponse creates a successful API response
func SuccessResponse(data interface{}) APIResponse {
return APIResponse{
Success: true,
Data: data,
Error: nil,
}
}
// ErrorResponse creates an error API response
func ErrorResponse(code, message string) APIResponse {
return APIResponse{
Success: false,
Data: nil,
Error: &APIError{
Code: code,
Message: message,
},
}
}
// Common error codes
const (
ErrCodeBadRequest = "BAD_REQUEST"
ErrCodeUnauthorized = "UNAUTHORIZED"
ErrCodeForbidden = "FORBIDDEN"
ErrCodeNotFound = "NOT_FOUND"
ErrCodeConflict = "CONFLICT"
ErrCodeInternalError = "INTERNAL_ERROR"
ErrCodeBucketExists = "BUCKET_ALREADY_EXISTS"
ErrCodeBucketNotFound = "BUCKET_NOT_FOUND"
ErrCodeObjectNotFound = "OBJECT_NOT_FOUND"
ErrCodeInvalidBucketName = "INVALID_BUCKET_NAME"
ErrCodeInvalidObjectKey = "INVALID_OBJECT_KEY"
ErrCodeUploadFailed = "UPLOAD_FAILED"
ErrCodeDeleteFailed = "DELETE_FAILED"
ErrCodeListFailed = "LIST_FAILED"
)
-247
View File
@@ -1,247 +0,0 @@
package routes
import (
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/middleware"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gofiber/fiber/v3"
// Swagger imports
_ "Noooste/garage-ui/docs"
"github.com/Noooste/swagger"
)
// SetupRoutes configures all API routes
func SetupRoutes(
app *fiber.App,
cfg *config.Config,
authService *auth.AuthService,
healthHandler *handlers.HealthHandler,
bucketHandler *handlers.BucketHandler,
objectHandler *handlers.ObjectHandler,
userHandler *handlers.UserHandler,
clusterHandler *handlers.ClusterHandler,
monitoringHandler *handlers.MonitoringHandler,
) {
// Apply CORS middleware globally
app.Use(middleware.CORSMiddleware(&cfg.CORS))
// Health check endpoint (no auth required)
app.Get("/health", healthHandler.Check)
app.Get("/api/v1/health", healthHandler.Check)
// Swagger documentation endpoint (no auth required)
app.Get("/docs/*", swagger.HandlerDefault)
// API v1 group
api := app.Group("/api/v1")
// Apply authentication middleware to all API routes
api.Use(middleware.AuthMiddleware(&cfg.Auth, authService))
// Bucket routes
buckets := api.Group("/buckets")
{
buckets.Get("/", bucketHandler.ListBuckets) // List all buckets
buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket
buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions
}
// Object routes
objects := api.Group("/buckets/:bucket/objects")
{
objects.Get("/", objectHandler.ListObjects) // List objects in bucket
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects
objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects
objects.Get("/:key", objectHandler.GetObject) // Download object
objects.Delete("/:key", objectHandler.DeleteObject) // Delete object
objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata
objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL
}
// User/Key management routes
users := api.Group("/users")
{
users.Get("/", userHandler.ListUsers) // List all users/keys
users.Post("/", userHandler.CreateUser) // Create new user/key
users.Get("/:access_key", userHandler.GetUser) // Get user info
users.Get("/:access_key/secret", userHandler.GetUserSecretKey) // Get user secret key
users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key
users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions
}
// Cluster management routes
cluster := api.Group("/cluster")
{
cluster.Get("/health", clusterHandler.GetHealth) // Get cluster health
cluster.Get("/status", clusterHandler.GetStatus) // Get cluster status
cluster.Get("/statistics", clusterHandler.GetStatistics) // Get cluster statistics
cluster.Get("/nodes/:node_id", clusterHandler.GetNodeInfo) // Get node info
cluster.Get("/nodes/:node_id/statistics", clusterHandler.GetNodeStatistics) // Get node statistics
}
// Monitoring routes
monitoring := api.Group("/monitoring")
{
monitoring.Get("/metrics", monitoringHandler.GetMetrics) // Get Prometheus metrics
monitoring.Get("/admin-health", monitoringHandler.CheckAdminHealth) // Check Admin API health
monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
}
// OIDC authentication routes (only if OIDC is enabled)
if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled {
authRoutes := app.Group("/auth")
{
// Login endpoint - redirects to OIDC provider
authRoutes.Get("/login", func(c fiber.Ctx) error {
state, err := authService.GenerateStateToken()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Failed to generate state token",
})
}
authURL, err := authService.GetAuthorizationURL(state)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Failed to generate login URL",
})
}
return c.Redirect().To(authURL)
})
// Callback endpoint - handles OIDC redirect after login
authRoutes.Get("/callback", func(c fiber.Ctx) error {
// Get and validate state token
state := c.Query("state")
if !authService.ValidateAndConsumeState(state) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid or expired state token",
})
}
// Get authorization code from query
code := c.Query("code")
if code == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Authorization code is required",
})
}
// Exchange code for tokens
ctx := c.Context()
token, err := authService.ExchangeCode(ctx, code)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Failed to exchange authorization code",
})
}
// Extract ID token from OAuth2 token
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "No ID token in response",
})
}
// Verify ID token and get user info
userInfo, err := authService.VerifyIDToken(ctx, rawIDToken)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Invalid ID token",
})
}
// Generate JWT session token
sessionToken, err := authService.GenerateSessionToken(userInfo)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Failed to create session",
})
}
// Set JWT session token as secure cookie
c.Cookie(&fiber.Cookie{
Name: cfg.Auth.OIDC.CookieName,
Value: sessionToken,
MaxAge: cfg.Auth.OIDC.SessionMaxAge,
Secure: cfg.Auth.OIDC.CookieSecure,
HTTPOnly: cfg.Auth.OIDC.CookieHTTPOnly,
SameSite: cfg.Auth.OIDC.CookieSameSite,
})
return c.JSON(fiber.Map{
"success": true,
"user": userInfo,
})
})
// Logout endpoint
authRoutes.Post("/logout", func(c fiber.Ctx) error {
// Clear session cookie
c.Cookie(&fiber.Cookie{
Name: cfg.Auth.OIDC.CookieName,
Value: "",
MaxAge: -1,
})
return c.JSON(fiber.Map{
"success": true,
"message": "Logged out successfully",
})
})
// User info endpoint
authRoutes.Get("/me", middleware.AuthMiddleware(&cfg.Auth, authService), func(c fiber.Ctx) error {
userInfo := c.Locals("userInfo")
if userInfo == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Not authenticated",
})
}
return c.JSON(fiber.Map{
"success": true,
"user": userInfo,
})
})
}
}
cfg.Server.FrontendPath = "./frontend/dist"
// Check if frontend path exists
if _, err := os.Stat(cfg.Server.FrontendPath); err == nil {
// SPA fallback - serve index.html for all non-API routes
app.Use(func(c fiber.Ctx) error {
path := c.Path()
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/health") ||
strings.HasPrefix(path, "/docs") {
fmt.Println("API or health check route, skipping SPA fallback:", path)
return c.Next()
}
// Try to serve static files first
filePath := filepath.Join(cfg.Server.FrontendPath, path)
if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
return c.SendFile(filePath)
}
// If no static file exists, serve index.html for SPA routing
indexPath := filepath.Join(cfg.Server.FrontendPath, "index.html")
return c.SendFile(indexPath)
})
}
}
-460
View File
@@ -1,460 +0,0 @@
package services
import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/Noooste/azuretls-client"
)
// GarageAdminService handles interactions with the Garage Admin API
type GarageAdminService struct {
baseURL string
token string
httpClient *azuretls.Session
}
// NewGarageAdminService creates a new Garage Admin API service
func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
session := azuretls.NewSession()
session.Log()
return &GarageAdminService{
baseURL: cfg.AdminEndpoint,
token: cfg.AdminToken,
httpClient: session,
}
}
// doRequest performs an HTTP request to the Admin API
func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
return s.httpClient.Do(&azuretls.Request{
Method: method,
Url: s.baseURL + path,
Body: body,
IgnoreBody: true, // decodeResponse will handle body reading
OrderedHeaders: azuretls.OrderedHeaders{
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
},
}, ctx)
}
// decodeResponse decodes a JSON response into the target structure
func decodeResponse(resp *azuretls.Response, target interface{}) error {
defer resp.RawBody.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.RawBody)
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
if target != nil {
if err := json.NewDecoder(resp.RawBody).Decode(target); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
}
return nil
}
// ====================================
// Access Key Operations
// ====================================
// ListKeys returns all access keys in the cluster
func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result []models.ListKeysResponseItem
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return result, nil
}
// CreateKey creates a new API access key
func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetKeyInfo returns information about a specific access key
func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID)
if showSecret {
path += "&showSecretKey=true"
}
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// UpdateKey updates information about an access key
func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v2/UpdateKey?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// DeleteKey deletes an access key from the cluster
func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error {
path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
}
return nil
}
// ImportKey imports an existing API access key
func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/ImportKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Bucket Operations (Admin API)
// ====================================
// ListBuckets returns all buckets in the cluster
func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListBuckets", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result []models.ListBucketsResponseItem
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return result, nil
}
// GetBucketInfo returns detailed information about a bucket by ID
func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
path := fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetBucketInfoByAlias returns detailed information about a bucket by its global alias
func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
path := fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err = decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// CreateBucket creates a new bucket via the Admin API
func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateBucket", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// UpdateBucket updates bucket settings
func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
path := fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID)
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// DeleteBucket deletes a bucket
func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error {
path := fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID)
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
}
return nil
}
// ====================================
// Bucket Alias Operations
// ====================================
// AddBucketAlias adds an alias to a bucket
func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// RemoveBucketAlias removes an alias from a bucket
func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/RemoveBucketAlias", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Permission Operations
// ====================================
// AllowBucketKey grants permissions for a key on a bucket
func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AllowBucketKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// DenyBucketKey revokes permissions for a key on a bucket
func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/DenyBucketKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Cluster Operations
// ====================================
// GetClusterHealth returns the health status of the cluster
func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.ClusterHealth
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetClusterStatus returns the current status of the cluster
func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatus", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.ClusterStatus
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetClusterStatistics returns global cluster statistics
func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatistics", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.ClusterStatistics
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Node Operations
// ====================================
// GetNodeInfo returns information about a specific node
func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.MultiNodeResponse
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetNodeStatistics returns statistics for a specific node
func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
path := fmt.Sprintf("/v2/GetNodeStatistics?node=%s", nodeID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.MultiNodeResponse
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ====================================
// Monitoring Operations
// ====================================
// HealthCheck checks if the Admin API is reachable
func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("health check returned error: %w", err)
}
return nil
}
// GetMetrics returns Prometheus metrics from the Admin API
func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.RawBody.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.RawBody)
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
bodyBytes, err := io.ReadAll(resp.RawBody)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(bodyBytes), nil
}
-513
View File
@@ -1,513 +0,0 @@
package services
import (
"context"
"fmt"
"io"
"strings"
"time"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// S3Service handles all S3 operations with Garage using MinIO SDK
type S3Service struct {
client *minio.Client
config *config.GarageConfig
adminService *GarageAdminService
}
// NewS3Service creates a new S3 service instance using MinIO SDK
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
// Create MinIO client for Garage
// trim http or https from endpoint
if strings.HasPrefix(cfg.Endpoint, "http://") {
cfg.Endpoint = strings.TrimPrefix(cfg.Endpoint, "http://")
}
if strings.HasPrefix(cfg.Endpoint, "https://") {
cfg.Endpoint = strings.TrimPrefix(cfg.Endpoint, "https://")
cfg.UseSSL = true
}
client, err := minio.New(cfg.Endpoint, &minio.Options{
//Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
Region: cfg.Region,
})
if err != nil {
panic(fmt.Errorf("failed to create MinIO client: %w", err))
}
return &S3Service{
client: client,
config: cfg,
adminService: adminService,
}
}
// getBucketCredentials retrieves credentials for a specific bucket
// It checks the cache first, then queries the Garage Admin API
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (*credentials.Credentials, error) {
cacheKey := fmt.Sprintf("key:%s", bucketName)
cacheData := utils.GlobalCache.Get(cacheKey)
if cacheData != nil {
return cacheData.(*credentials.Credentials), nil
}
// Get bucket info from Garage Admin API
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get bucket info: %w", err)
}
// Find a key with read and write permissions
var accessKeyID, secretAccessKey string
for _, keyInfo := range bucketInfo.Keys {
if !keyInfo.Permissions.Read || !keyInfo.Permissions.Write {
continue
}
// Get key details with secret
keyDetails, err := s.adminService.GetKeyInfo(ctx, keyInfo.AccessKeyID, true)
if err != nil {
return nil, fmt.Errorf("failed to get key info: %w", err)
}
if keyDetails.SecretAccessKey != nil {
accessKeyID = keyDetails.AccessKeyID
secretAccessKey = *keyDetails.SecretAccessKey
break
}
}
if accessKeyID == "" || secretAccessKey == "" {
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
}
// Create credentials
creds := credentials.NewStaticV4(accessKeyID, secretAccessKey, "")
// Cache credentials for 1 hour
utils.GlobalCache.Set(cacheKey, creds, time.Hour)
return creds, nil
}
// getMinioClient creates a MinIO client for a specific bucket with dynamic credentials
func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*minio.Client, error) {
creds, err := s.getBucketCredentials(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucketName, err)
}
// Create MinIO client with bucket-specific credentials
client, err := minio.New(s.config.Endpoint, &minio.Options{
Creds: creds,
Secure: s.config.UseSSL,
Region: s.config.Region,
})
if err != nil {
return nil, fmt.Errorf("failed to create MinIO client for bucket %s: %w", bucketName, err)
}
return client, nil
}
// ListBuckets retrieves all buckets from Garage
func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) {
// Call MinIO ListBuckets API
bucketInfos, err := s.client.ListBuckets(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list buckets: %w", err)
}
// Convert MinIO buckets to our model
buckets := make([]models.BucketInfo, 0, len(bucketInfos))
for _, bucket := range bucketInfos {
buckets = append(buckets, models.BucketInfo{
Name: bucket.Name,
CreationDate: bucket.CreationDate,
})
}
return &models.BucketListResponse{
Buckets: buckets,
Count: len(buckets),
}, nil
}
// CreateBucket creates a new bucket in Garage
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Call MinIO MakeBucket API
err = client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
Region: s.config.Region,
})
if err != nil {
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
}
return nil
}
// DeleteBucket deletes a bucket from Garage
func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Call MinIO RemoveBucket API
err = client.RemoveBucket(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
}
return nil
}
// ListObjects lists objects in a bucket with optional prefix filter and pagination
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Set default max keys if not specified
if maxKeys <= 0 {
maxKeys = 1000
}
// Use ListObjectsV2 for proper pagination support
opts := minio.ListObjectsOptions{
Prefix: prefix,
Recursive: false,
MaxKeys: maxKeys,
StartAfter: continuationToken,
UseV1: false,
}
objects := make([]models.ObjectInfo, 0)
prefixesMap := make(map[string]bool)
var lastKey string
isTruncated := false
itemCount := 0
// List objects using the channel-based API
for object := range client.ListObjects(ctx, bucketName, opts) {
if object.Err != nil {
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, object.Err)
}
// Check if this is a prefix (directory)
if len(object.Key) > 0 && object.Key[len(object.Key)-1:] == "/" && object.Size == 0 {
prefixesMap[object.Key] = true
continue
}
// Track the last key for pagination
lastKey = object.Key
// Add to objects list
objects = append(objects, models.ObjectInfo{
Key: object.Key,
Size: object.Size,
LastModified: object.LastModified,
ETag: object.ETag,
ContentType: object.ContentType,
StorageClass: object.StorageClass,
})
itemCount++
if itemCount >= maxKeys {
isTruncated = true
break
}
}
// Convert prefixes map to slice
prefixList := make([]string, 0, len(prefixesMap))
for p := range prefixesMap {
prefixList = append(prefixList, p)
}
// Prepare next continuation token
var nextToken string
if isTruncated && lastKey != "" {
nextToken = lastKey
}
return &models.ObjectListResponse{
Bucket: bucketName,
Objects: objects,
Prefixes: prefixList,
Count: len(objects),
IsTruncated: isTruncated,
NextContinuationToken: nextToken,
}, nil
}
// UploadObject uploads an object to a bucket
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Upload options
opts := minio.PutObjectOptions{
ContentType: contentType,
}
// Call MinIO PutObject API
info, err := client.PutObject(ctx, bucketName, key, body, -1, opts)
if err != nil {
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
}
return &models.ObjectUploadResponse{
Bucket: bucketName,
Key: key,
ETag: info.ETag,
Size: info.Size,
ContentType: contentType,
}, nil
}
// GetObject retrieves an object from a bucket
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
// Call MinIO GetObject API
object, err := s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
if err != nil {
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
}
// Get object info
stat, err := object.Stat()
if err != nil {
object.Close()
return nil, nil, fmt.Errorf("failed to get object info for %s in bucket %s: %w", key, bucketName, err)
}
// Create object info
objectInfo := &models.ObjectInfo{
Key: key,
Size: stat.Size,
LastModified: stat.LastModified,
ETag: stat.ETag,
ContentType: stat.ContentType,
}
return object, objectInfo, nil
}
// DeleteObject deletes an object from a bucket
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
// Call MinIO RemoveObject API
err := s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
if err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
}
return nil
}
// ObjectExists checks if an object exists in a bucket
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return false, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
_, err = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
if err != nil {
// Check if error is "object not found"
errResponse := minio.ToErrorResponse(err)
if errResponse.Code == "NoSuchKey" {
return false, nil
}
return false, fmt.Errorf("failed to check if object exists: %w", err)
}
return true, nil
}
// GetObjectMetadata retrieves metadata for an object without downloading it
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
stat, err := client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
}
return &models.ObjectInfo{
Key: key,
Size: stat.Size,
LastModified: stat.LastModified,
ETag: stat.ETag,
ContentType: stat.ContentType,
}, nil
}
// DeleteMultipleObjects deletes multiple objects from a bucket
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
if len(keys) == 0 {
return nil
}
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Create channel for objects to delete
objectsCh := make(chan minio.ObjectInfo)
// Send objects to delete in a goroutine
go func() {
defer close(objectsCh)
for _, key := range keys {
objectsCh <- minio.ObjectInfo{
Key: key,
}
}
}()
// Call MinIO RemoveObjects API (batch delete)
errorCh := client.RemoveObjects(ctx, bucketName, objectsCh, minio.RemoveObjectsOptions{})
// Check for errors
for err := range errorCh {
if err.Err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", err.ObjectName, bucketName, err.Err)
}
}
return nil
}
// GetPresignedURL generates a pre-signed URL for temporary access to an object
// This is useful for sharing files without exposing credentials
func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return "", fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Generate presigned GET URL
presignedURL, err := client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil)
if err != nil {
return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err)
}
return presignedURL.String(), nil
}
// UploadResult represents the result of a single file upload
type UploadResult struct {
Key string
Success bool
Error error
ETag string
Size int64
ContentType string
}
// UploadMultipleObjects uploads multiple objects to a bucket
// It handles uploads in batches to respect any S3/Garage limits
// Returns results for each file, including both successes and failures
func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
Key string
Body io.Reader
ContentType string
}) []UploadResult {
results := make([]UploadResult, len(files))
// Get bucket-specific MinIO client once for all uploads
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
// If we can't get the client, all uploads fail
for i := range files {
results[i] = UploadResult{
Key: files[i].Key,
Success: false,
Error: fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err),
}
}
return results
}
// Upload each file
for i, file := range files {
// Upload options
opts := minio.PutObjectOptions{
ContentType: file.ContentType,
}
// Attempt upload
info, err := client.PutObject(ctx, bucketName, file.Key, file.Body, -1, opts)
if err != nil {
results[i] = UploadResult{
Key: file.Key,
Success: false,
Error: fmt.Errorf("failed to upload object %s: %w", file.Key, err),
ContentType: file.ContentType,
}
continue
}
results[i] = UploadResult{
Key: file.Key,
Success: true,
Error: nil,
ETag: info.ETag,
Size: info.Size,
ContentType: file.ContentType,
}
}
return results
}
// BucketStatistics holds statistical information about a bucket
type BucketStatistics struct {
ObjectCount int64
TotalSize int64
}
// GetBucketStatistics retrieves bucket statistics from Garage Admin API
// This is much more efficient than iterating through all objects
func (s *S3Service) GetBucketStatistics(ctx context.Context, bucketName string) (*BucketStatistics, error) {
// Get bucket info from Garage Admin API which includes object count and size
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get bucket info for %s: %w", bucketName, err)
}
// Return statistics from Admin API
return &BucketStatistics{
ObjectCount: bucketInfo.Objects,
TotalSize: bucketInfo.Bytes,
}, nil
}
-172
View File
@@ -1,172 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/routes"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/logger"
"github.com/gofiber/fiber/v3/middleware/recover"
)
// @title Garage UI API
// @version 0.1.0
// @description REST API for managing Garage distributed object storage system
// @description This API provides endpoints for managing buckets, objects, users, and cluster operations.
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.email support@garage-ui.io
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
// @host localhost:8080
// @BasePath /
// @schemes http https
// @tag.name Health
// @tag.description Health check endpoints
// @tag.name Buckets
// @tag.description Bucket management operations
// @tag.name Objects
// @tag.description Object storage and retrieval operations
// @tag.name Users
// @tag.description User and access key management
// @tag.name Cluster
// @tag.description Cluster status and node management
// @tag.name Monitoring
// @tag.description Monitoring and metrics endpoints
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and JWT token.
const version = "0.1.0"
func main() {
// Parse command-line flags
configPath := flag.String("config", "config.yaml", "Path to configuration file")
flag.Parse()
// Load configuration
log.Printf("Loading configuration from: %s", *configPath)
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
log.Printf("Starting Garage UI Backend v%s in %s mode", version, cfg.Server.Environment)
// Initialize services
log.Println("Initializing Garage Admin service...")
adminService := services.NewGarageAdminService(&cfg.Garage)
log.Println("Initializing S3 service...")
s3Service := services.NewS3Service(&cfg.Garage, adminService)
log.Printf("Initializing authentication service (mode: %s)...", cfg.Auth.Mode)
authService, err := auth.NewAuthService(&cfg.Auth)
if err != nil {
log.Fatalf("Failed to initialize auth service: %v", err)
}
// Initialize handlers
healthHandler := handlers.NewHealthHandler(version)
bucketHandler := handlers.NewBucketHandler(adminService, s3Service)
objectHandler := handlers.NewObjectHandler(s3Service)
userHandler := handlers.NewUserHandler(adminService)
clusterHandler := handlers.NewClusterHandler(adminService)
monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service)
// Create Fiber app with configuration
app := fiber.New(fiber.Config{
AppName: "Garage UI Backend v" + version,
//DisableStartupMessage: false,
//EnablePrintRoutes: cfg.IsDevelopment(),
ErrorHandler: customErrorHandler,
})
// Apply global middleware
app.Use(recover.New()) // Panic recovery
app.Use(logger.New(logger.Config{
Format: "[${time}] ${status} - ${method} ${path} (${latency})\n",
}))
// Setup routes
log.Println("Setting up routes...")
routes.SetupRoutes(
app,
cfg,
authService,
healthHandler,
bucketHandler,
objectHandler,
userHandler,
clusterHandler,
monitoringHandler,
)
// Start server in a goroutine
go func() {
addr := cfg.GetAddress()
log.Printf("Server listening on %s", addr)
log.Printf("Health check available at: http://%s/health", addr)
log.Printf("API documentation: http://%s/api/v1/", addr)
if err := app.Listen(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}()
// Wait for interrupt signal to gracefully shutdown the server
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
if err := app.Shutdown(); err != nil {
log.Fatalf("Server shutdown failed: %v", err)
}
log.Println("Server stopped gracefully")
}
// customErrorHandler handles errors globally
func customErrorHandler(c fiber.Ctx, err error) error {
// Default to 500 Internal Server Error
code := fiber.StatusInternalServerError
// Check if it's a Fiber error
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
// Log the error
log.Printf("Error: %v", err)
// Return JSON error response
return c.Status(code).JSON(fiber.Map{
"success": false,
"error": fiber.Map{
"code": fmt.Sprintf("ERROR_%d", code),
"message": err.Error(),
},
})
}
-121
View File
@@ -1,121 +0,0 @@
package logger
import (
"io"
"os"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// Logger is a wrapper around zerolog.Logger
type Logger struct {
zerolog.Logger
}
var (
// Global logger instance
globalLogger *Logger
)
// Config holds logger configuration
type Config struct {
Level string // debug, info, warn, error
Pretty bool // Enable console pretty printing
TimeFormat string // Time format (default: time.RFC3339)
}
// Init initializes the global logger with the given configuration
func Init(cfg Config) {
var output io.Writer = os.Stdout
// Set up pretty console output if enabled
if cfg.Pretty {
output = zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: time.RFC3339,
NoColor: false,
}
}
// Parse log level
level := zerolog.InfoLevel
switch cfg.Level {
case "debug":
level = zerolog.DebugLevel
case "info":
level = zerolog.InfoLevel
case "warn":
level = zerolog.WarnLevel
case "error":
level = zerolog.ErrorLevel
}
// Create logger
logger := zerolog.New(output).
Level(level).
With().
Timestamp().
Caller().
Logger()
globalLogger = &Logger{logger}
log.Logger = logger
}
// Get returns the global logger instance
func Get() *Logger {
if globalLogger == nil {
// Initialize with defaults if not initialized
Init(Config{
Level: "info",
Pretty: true,
})
}
return globalLogger
}
// Debug logs a debug message
func Debug() *zerolog.Event {
return Get().Debug()
}
// Info logs an info message
func Info() *zerolog.Event {
return Get().Info()
}
// Warn logs a warning message
func Warn() *zerolog.Event {
return Get().Warn()
}
// Error logs an error message
func Error() *zerolog.Event {
return Get().Error()
}
// Fatal logs a fatal message and exits
func Fatal() *zerolog.Event {
return Get().Fatal()
}
// WithContext creates a new logger with additional context fields
func (l *Logger) WithContext(fields map[string]interface{}) *Logger {
ctx := l.Logger.With()
for k, v := range fields {
ctx = ctx.Interface(k, v)
}
return &Logger{ctx.Logger()}
}
// WithComponent creates a logger with a component field
func WithComponent(component string) *Logger {
return &Logger{Get().With().Str("component", component).Logger()}
}
// WithError creates a logger with an error field
func WithError(err error) *zerolog.Event {
return Get().Error().Err(err)
}
-95
View File
@@ -1,95 +0,0 @@
package utils
import (
"sync"
"time"
)
// CacheItem represents a cached item with expiration
type CacheItem struct {
Value interface{}
Expiration time.Time
}
// Cache represents a simple in-memory cache with expiration
type Cache struct {
mu sync.RWMutex
items map[string]CacheItem
}
// NewCache creates a new cache instance
func NewCache() *Cache {
c := &Cache{
items: make(map[string]CacheItem),
}
// Start cleanup goroutine
go c.cleanupExpired()
return c
}
// Get retrieves a value from the cache
func (c *Cache) Get(key string) interface{} {
c.mu.RLock()
defer c.mu.RUnlock()
item, exists := c.items[key]
if !exists {
return nil
}
// Check if item has expired
if time.Now().After(item.Expiration) {
return nil
}
return item.Value
}
// Set stores a value in the cache with an expiration duration
func (c *Cache) Set(key string, value interface{}, duration time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = CacheItem{
Value: value,
Expiration: time.Now().Add(duration),
}
}
// Delete removes a value from the cache
func (c *Cache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, key)
}
// Clear removes all items from the cache
func (c *Cache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.items = make(map[string]CacheItem)
}
// cleanupExpired periodically removes expired items
func (c *Cache) cleanupExpired() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for key, item := range c.items {
if now.After(item.Expiration) {
delete(c.items, key)
}
}
c.mu.Unlock()
}
}
// Global cache instance
var GlobalCache = NewCache()
-92
View File
@@ -1,92 +0,0 @@
# Garage UI Backend Configuration
# Server configuration
server:
host: "0.0.0.0"
port: 8080
environment: "development" # development, production
# Garage S3 Configuration
garage:
endpoint: "http://localhost:3900" # Garage S3 API endpoint
region: "eu-west-1" # S3 region (can be any value for Garage)
# Garage Admin API configuration
admin_endpoint: "http://localhost:3903" # Garage Admin API endpoint
admin_token: "changeme" # Admin API bearer token
# Authentication Configuration
auth:
# Auth mode: "none", "basic", or "oidc"
mode: "none"
# Basic Authentication (only used when mode = "basic")
basic:
username: "admin"
password: "changeme"
# OIDC Configuration (only used when mode = "oidc")
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
# OIDC scopes to request
scopes:
- openid
- email
- profile
# OIDC Provider URLs
issuer_url: "https://keycloak.example.com/realms/master"
auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
# Token validation
skip_issuer_check: false
skip_expiry_check: false
# Attribute mappings
email_attribute: "email"
username_attribute: "preferred_username"
name_attribute: "name"
# Role-based access (optional)
role_attribute_path: "resource_access.garage-ui.roles"
admin_role: "admin"
# TLS configuration
tls_skip_verify: false # Only set to true for testing, not recommended for production
# Session configuration
session_max_age: 86400 # 24 hours in seconds
cookie_name: "garage_session"
cookie_secure: false # Set to true in production with HTTPS
cookie_http_only: true
cookie_same_site: "lax" # lax, strict, none
# CORS Configuration (for frontend)
cors:
enabled: true
allowed_origins:
- "*" # Vite default
allowed_methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowed_headers:
- Origin
- Content-Type
- Accept
- Authorization
allow_credentials: false
max_age: 3600
# Logging
logging:
level: "info" # debug, info, warn, error
format: "json" # json, text
-39
View File
@@ -1,39 +0,0 @@
---
services:
garage:
image: dxflrs/garage:v2.0.0
container_name: garage
volumes:
- ./garage.toml:/etc/garage.toml
- ./meta:/var/lib/garage/meta
- ./data:/var/lib/garage/data
restart: unless-stopped
ports:
- "3900:3900"
- "3901:3901"
- "3902:3903"
- "3903:3903"
garage-ui:
image: noooste/garage-ui:latest
container_name: garage-ui
restart: unless-stopped
volumes:
- ./config.yaml:/app/config.yaml
ports:
- "127.0.0.1:8080:8080"
depends_on:
- garage
environment:
# Garage S3 Configuration
GARAGE_UI_GARAGE_ENDPOINT: "garage:3900"
GARAGE_UI_GARAGE_ADMIN_ENDPOINT: "http://garage:3903"
# Server Configuration
GARAGE_UI_SERVER_HOST: "0.0.0.0"
GARAGE_UI_SERVER_PORT: "8080"
GARAGE_UI_SERVER_ENVIRONMENT: "production"
# Logging
GARAGE_UI_LOGGING_LEVEL: "info"
GARAGE_UI_LOGGING_FORMAT: "json"
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-171
View File
@@ -1,171 +0,0 @@
# Garage UI - Frontend
Modern React frontend for Garage S3-compatible object storage management.
## Features
- **Dashboard** - Overview of storage metrics and recent activity
- **Bucket Management** - Create, view, and manage S3 buckets
- **Object Browser** - Navigate folders, upload/download files with drag-and-drop
- **Access Control** - Manage API keys and permissions
- **Dark/Light Mode** - System-aware theme with manual toggle
- **Data-Dense UI** - Optimized for power users with comprehensive tables
## Tech Stack
- **Framework**: React 19 + TypeScript
- **Build Tool**: Vite 7
- **Routing**: React Router v6
- **State Management**: Zustand
- **UI Components**: shadcn/ui (built on Radix UI primitives)
- **Styling**: Tailwind CSS v4
- **Forms**: React Hook Form + Zod
- **Tables**: TanStack Table
- **File Upload**: React Dropzone
- **Icons**: Lucide React
## Getting Started
### Prerequisites
- Node.js 18+ and npm
### Installation
1. Install dependencies:
```bash
npm install
```
2. Create environment file:
```bash
cp .env.example .env
```
3. Update `.env` with your backend API URL:
```
VITE_API_URL=http://localhost:8080/api
```
### Development
Start the development server:
```bash
npm run dev
```
The app will be available at http://localhost:3000
### Building for Production
Build the application:
```bash
npm run build
```
Preview the production build:
```bash
npm run preview
```
## Project Structure
```
src/
├── components/
│ ├── ui/ # shadcn/ui base components
│ ├── layout/ # Layout components (Sidebar, Header)
│ ├── buckets/ # Bucket-specific components
│ ├── objects/ # Object browser components
│ └── access/ # Access control components
├── pages/ # Page components
│ ├── Dashboard.tsx
│ ├── Buckets.tsx
│ ├── Objects.tsx
│ └── AccessControl.tsx
├── lib/
│ ├── api.ts # API client with mock data
│ └── utils.ts # Utility functions
├── hooks/ # Custom React hooks
├── types/ # TypeScript type definitions
└── stores/ # Zustand stores
```
## API Integration
The app is currently using mock data. To connect to your Go backend:
1. Ensure your backend is running on `http://localhost:8080`
2. In `src/lib/api.ts`, uncomment the real API calls
3. Comment out or remove the mock data returns
Example:
```typescript
// Before (mock data)
list: async (): Promise<Bucket[]> => {
return mockBuckets;
},
// After (real API)
list: async (): Promise<Bucket[]> => {
const response = await api.get('/buckets');
return response.data;
},
```
## Features by Page
### Dashboard
- Storage metrics overview
- Bucket count and object statistics
- Storage distribution visualization
- Recent buckets list
### Buckets
- List all buckets with search
- Create new buckets
- View bucket details
- Delete buckets
- Region information
### Objects
- Browse objects and folders
- Breadcrumb navigation
- Drag-and-drop file upload
- Multiple file selection
- Download objects
- Delete objects
- Object metadata
### Access Control
- API key management
- Create keys with permissions
- Activate/deactivate keys
- Copy access key IDs
- Permission configuration
- Bucket policies (coming soon)
## Theme Customization
The app uses CSS variables for theming. Customize colors in `src/index.css`:
```css
:root {
--primary: 240 5.9% 10%;
--secondary: 240 4.8% 95.9%;
/* ... more variables */
}
```
## Contributing
This is a custom frontend for Garage storage. Feel free to extend with additional features:
- Analytics dashboards
- Versioning management
- Lifecycle policies UI
- Replication configuration
- Metrics visualization
## License
MIT
-23
View File
@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/garage.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Garage UI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-5362
View File
File diff suppressed because it is too large Load Diff
-51
View File
@@ -1,51 +0,0 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-router-dom": "^7.9.6",
"recharts": "^3.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.1.13",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.17",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
-5
View File
@@ -1,5 +0,0 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
-42
View File
@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
-38
View File
@@ -1,38 +0,0 @@
import {BrowserRouter, Route, Routes} from 'react-router-dom';
import {QueryClientProvider} from '@tanstack/react-query';
import {ThemeProvider, useTheme} from '@/components/theme-provider';
import {Layout} from '@/components/layout/layout';
import {Dashboard} from '@/pages/Dashboard';
import {Buckets} from '@/pages/Buckets';
import {Cluster} from '@/pages/Cluster';
import {AccessControl} from '@/pages/AccessControl';
import {Toaster} from 'sonner';
import {queryClient} from '@/lib/query-client';
function ThemedToaster() {
const { theme } = useTheme();
return <Toaster richColors position="bottom-right" theme={theme} />;
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="system" storageKey="Noooste/garage-ui-theme">
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="buckets" element={<Buckets />} />
<Route path="cluster" element={<Cluster />} />
<Route path="access" element={<AccessControl />} />
</Route>
</Routes>
</BrowserRouter>
<ThemedToaster />
</ThemeProvider>
</QueryClientProvider>
);
}
export default App;
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,147 +0,0 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
import { formatBytes, formatDate } from '@/lib/utils';
import type { Bucket } from '@/types';
interface BucketListViewProps {
buckets: Bucket[];
searchQuery: string;
isLoading?: boolean;
onSearchChange: (query: string) => void;
onViewBucket: (bucketName: string) => void;
onOpenSettings: (bucket: Bucket) => void;
onCreateBucket: () => void;
onDeleteBucket: (bucket: Bucket) => void;
}
export function BucketListView({
buckets,
searchQuery,
isLoading = false,
onSearchChange,
onViewBucket,
onOpenSettings,
onCreateBucket,
onDeleteBucket,
}: BucketListViewProps) {
const filteredBuckets = buckets.filter((bucket) =>
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="space-y-4 sm:space-y-6">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative flex-1 max-w-full sm:max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search buckets..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
<Button onClick={onCreateBucket} className="w-full sm:w-auto">
<Plus className="h-4 w-4" />
Create Bucket
</Button>
</div>
{/* Buckets Table */}
<div className="border rounded-lg overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">Region</TableHead>
<TableHead className="hidden md:table-cell">Objects</TableHead>
<TableHead>Size</TableHead>
<TableHead className="hidden lg:table-cell">Created</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading buckets...</span>
</div>
</TableCell>
</TableRow>
) : filteredBuckets.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
{searchQuery ? 'No buckets found matching your search' : 'No buckets yet'}
</TableCell>
</TableRow>
) : (
filteredBuckets.map((bucket) => (
<TableRow
key={bucket.name}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onViewBucket(bucket.name)}
>
<TableCell className="font-medium truncate max-w-[200px]">{bucket.name}</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">{bucket.objectCount?.toLocaleString() || 0}</TableCell>
<TableCell>{bucket.size ? formatBytes(bucket.size) : '0 B'}</TableCell>
<TableCell className="hidden lg:table-cell">{formatDate(bucket.creationDate)}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="-m-3 top-1 relative">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onViewBucket(bucket.name);
}}>
<FolderIcon className="h-4 w-4" />
View Objects
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onOpenSettings(bucket);
}}>
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteBucket(bucket);
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
@@ -1,206 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectOption } from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { accessApi } from '@/lib/api';
import type { AccessKey, Bucket } from '@/types';
import { toast } from 'sonner';
interface BucketSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bucket: Bucket | null;
onGrantPermission: (bucketName: string, accessKeyId: string, permissions: { read: boolean; write: boolean; owner: boolean }) => Promise<boolean>;
}
export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermission }: BucketSettingsDialogProps) {
const [availableKeys, setAvailableKeys] = useState<AccessKey[]>([]);
const [selectedAccessKey, setSelectedAccessKey] = useState<string>('');
const [permissionRead, setPermissionRead] = useState(false);
const [permissionWrite, setPermissionWrite] = useState(false);
const [permissionOwner, setPermissionOwner] = useState(false);
useEffect(() => {
if (open && bucket) {
loadAccessKeys();
resetForm();
}
}, [open, bucket]);
const loadAccessKeys = async () => {
try {
const keys = await accessApi.listKeys();
setAvailableKeys(keys);
} catch (error) {
console.error('Failed to load access keys:', error);
}
};
const resetForm = () => {
setSelectedAccessKey('');
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
};
const handleAccessKeyChange = (accessKeyId: string) => {
setSelectedAccessKey(accessKeyId);
if (!accessKeyId) {
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
return;
}
const selectedKey = availableKeys.find(key => key.accessKeyId === accessKeyId);
if (selectedKey && bucket) {
const bucketPermission = selectedKey.permissions.find(
perm => perm.bucketName === bucket.name || perm.bucketId === bucket.name
);
if (bucketPermission) {
setPermissionRead(bucketPermission.read);
setPermissionWrite(bucketPermission.write);
setPermissionOwner(bucketPermission.owner);
} else {
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
}
}
};
const handleGrantPermission = async () => {
if (!bucket || !selectedAccessKey) {
toast.error('Please select an access key');
return;
}
if (!permissionRead && !permissionWrite && !permissionOwner) {
toast.error('Please select at least one permission');
return;
}
const success = await onGrantPermission(bucket.name, selectedAccessKey, {
read: permissionRead,
write: permissionWrite,
owner: permissionOwner,
});
if (success) {
resetForm();
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Bucket Settings - {bucket?.name}</DialogTitle>
<DialogDescription>
Grant access key permissions for this bucket
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Select Access Key</label>
<Select
value={selectedAccessKey}
onChange={(value) => handleAccessKeyChange(value)}
>
<SelectOption value="">-- Select an access key --</SelectOption>
{availableKeys.map((key) => (
<SelectOption key={key.accessKeyId} value={key.accessKeyId}>
{key.name} ({key.accessKeyId})
</SelectOption>
))}
</Select>
<p className="text-xs text-muted-foreground">
Choose which access key should have permissions on this bucket. Current permissions will be displayed when selected.
</p>
</div>
<div className="space-y-3">
<label className="text-sm font-medium">Permissions</label>
<div className="space-y-3 border rounded-lg p-4">
<div className="flex items-start space-x-3">
<Checkbox
id="permission-read"
checked={permissionRead}
onCheckedChange={(checked) => setPermissionRead(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="permission-read"
className="text-sm font-medium leading-none cursor-pointer"
>
Read
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows reading objects from the bucket (GetObject, HeadObject, ListObjects)
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="permission-write"
checked={permissionWrite}
onCheckedChange={(checked) => setPermissionWrite(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="permission-write"
className="text-sm font-medium leading-none cursor-pointer"
>
Write
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows writing and deleting objects in the bucket (PutObject, DeleteObject)
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="permission-owner"
checked={permissionOwner}
onCheckedChange={(checked) => setPermissionOwner(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="permission-owner"
className="text-sm font-medium leading-none cursor-pointer"
>
Owner
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy)
</p>
</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleGrantPermission} disabled={!selectedAccessKey}>
Grant Permission
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,78 +0,0 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
interface CreateBucketDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateBucket: (name: string) => Promise<boolean>;
}
export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) {
const [bucketName, setBucketName] = useState('');
const handleCreate = async () => {
if (!bucketName) {
toast.error('Please enter a bucket name');
return;
}
const success = await onCreateBucket(bucketName);
if (success) {
setBucketName('');
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Bucket</DialogTitle>
<DialogDescription>
Create a new storage bucket for your objects
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Bucket Name</label>
<Input
placeholder="my-bucket-name"
value={bucketName}
onChange={(e) => setBucketName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreate();
}
}}
/>
<p className="text-xs text-muted-foreground">
Must be unique and follow DNS naming conventions
</p>
</div>
</div>
<DialogFooter className="space-y-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant={!bucketName ? 'default_disabled' : 'default'}
onClick={handleCreate}
disabled={!bucketName}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,72 +0,0 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
interface CreateDirectoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentPath: string;
onCreateDirectory: (name: string) => Promise<boolean>;
}
export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreateDirectory }: CreateDirectoryDialogProps) {
const [dirName, setDirName] = useState('');
const handleCreate = async () => {
if (!dirName) {
toast.error('Please enter a directory name');
return;
}
const success = await onCreateDirectory(dirName);
if (success) {
setDirName('');
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Directory</DialogTitle>
<DialogDescription>
Create a new directory in {currentPath || 'the root'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Directory Name</label>
<Input
placeholder="my-directory"
value={dirName}
onChange={(e) => setDirName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreate();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!dirName}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,49 +0,0 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { Bucket } from '@/types';
interface DeleteBucketDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bucket: Bucket | null;
onDeleteBucket: (name: string) => Promise<boolean>;
}
export function DeleteBucketDialog({ open, onOpenChange, bucket, onDeleteBucket }: DeleteBucketDialogProps) {
const handleDelete = async () => {
if (!bucket) return;
const success = await onDeleteBucket(bucket.name);
if (success) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Bucket</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{bucket?.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,49 +0,0 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { S3Object } from '@/types';
interface DeleteObjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
object: S3Object | null;
onDeleteObject: (key: string) => Promise<boolean>;
}
export function DeleteObjectDialog({ open, onOpenChange, object, onDeleteObject }: DeleteObjectDialogProps) {
const handleDelete = async () => {
if (!object) return;
const success = await onDeleteObject(object.key);
if (success) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Object</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{object?.key}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,388 +0,0 @@
import {useState} from 'react';
import {useDropzone} from 'react-dropzone';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Header} from '@/components/layout/header';
import {ObjectsTable} from './ObjectsTable';
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
import {DeleteObjectDialog} from './DeleteObjectDialog';
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload} from 'lucide-react';
import {getBreadcrumbs} from '@/lib/file-utils';
import type {S3Object} from '@/types';
interface ObjectBrowserViewProps {
bucketName: string;
objects: S3Object[];
currentPath: string;
searchQuery: string;
isLoading?: boolean;
isTruncated?: boolean;
nextContinuationToken?: string;
itemsPerPage: number;
onSearchChange: (query: string) => void;
onNavigateToFolder: (path: string) => void;
onBackToBuckets: () => void;
onUploadFiles: (files: File[]) => Promise<boolean>;
onDeleteObject: (key: string) => Promise<boolean>;
onDeleteMultipleObjects: (keys: string[]) => Promise<boolean>;
onCreateDirectory: (name: string) => Promise<boolean>;
onRefresh: () => Promise<void>;
onPageChange: (token?: string) => void;
onItemsPerPageChange: (count: number) => void;
isRefreshing: boolean;
isNavigating: boolean;
initialPageToken?: string;
initialItemsPerPage?: number;
}
export function ObjectBrowserView({
bucketName,
objects,
currentPath,
searchQuery,
isLoading = false,
isTruncated = false,
nextContinuationToken,
itemsPerPage,
onSearchChange,
onNavigateToFolder,
onBackToBuckets,
onUploadFiles,
onDeleteObject,
onDeleteMultipleObjects,
onCreateDirectory,
onRefresh,
onPageChange,
onItemsPerPageChange,
isRefreshing,
isNavigating,
initialPageToken,
initialItemsPerPage,
}: ObjectBrowserViewProps) {
const [showUploadZone, setShowUploadZone] = useState(false);
const [deleteObjectDialogOpen, setDeleteObjectDialogOpen] = useState(false);
const [selectedObject, setSelectedObject] = useState<S3Object | null>(null);
const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false);
const [selectedFileKeys, setSelectedFileKeys] = useState<Set<string>>(new Set());
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: async (acceptedFiles, _fileRejections, event) => {
// Get files with their full paths from DataTransferItems API
const filesWithPaths: File[] = [];
// Type cast event to DragEvent to access dataTransfer
const dragEvent = event as DragEvent;
if (dragEvent.dataTransfer?.items) {
// Use DataTransferItemList API to preserve folder structure
const items = Array.from(dragEvent.dataTransfer.items);
await Promise.all(items.map(async (item: DataTransferItem) => {
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry?.();
if (entry) {
await traverseFileTree(entry, '', filesWithPaths);
}
}
}));
} else {
// Fallback to standard files
filesWithPaths.push(...acceptedFiles);
}
await onUploadFiles(filesWithPaths.length > 0 ? filesWithPaths : acceptedFiles);
setShowUploadZone(false);
},
noClick: true,
});
// Helper function to traverse file/directory tree
const traverseFileTree = async (item: any, path: string, files: File[]): Promise<void> => {
return new Promise((resolve) => {
if (item.isFile) {
item.file((file: File) => {
const fullPath = path + file.name;
Object.defineProperty(file, 'webkitRelativePath', {
value: fullPath,
writable: false
});
files.push(file);
resolve();
});
} else if (item.isDirectory) {
const dirReader = item.createReader();
dirReader.readEntries(async (entries: any[]) => {
for (const entry of entries) {
await traverseFileTree(entry, path + item.name + '/', files);
}
resolve();
});
} else {
resolve();
}
});
};
const handleToggleFileSelection = (key: string) => {
const newSelected = new Set(selectedFileKeys);
if (newSelected.has(key)) {
newSelected.delete(key);
} else {
newSelected.add(key);
}
setSelectedFileKeys(newSelected);
};
const handleSelectAllFiles = () => {
const fileKeys = objects
.filter(obj => !obj.isFolder)
.map(obj => obj.key);
if (selectedFileKeys.size === fileKeys.length && fileKeys.length > 0) {
setSelectedFileKeys(new Set());
} else {
setSelectedFileKeys(new Set(fileKeys));
}
};
const handleBulkDeleteFiles = async () => {
if (selectedFileKeys.size === 0) return;
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
setSelectedFileKeys(new Set());
};
const handleDeleteObject = async (key: string): Promise<boolean> => {
const success = await onDeleteObject(key);
if (success) {
setDeleteObjectDialogOpen(false);
setSelectedObject(null);
}
return success;
};
const uploadFiles = async (files: File[]) => {
await onUploadFiles(files);
setShowUploadZone(false);
};
return (
<div>
<Header title={`Objects in ${bucketName}`} />
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* Back Button */}
<Button variant="outline" onClick={onBackToBuckets} className="text-sm sm:text-base">
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">Back to Buckets</span>
<span className="sm:hidden">Back</span>
</Button>
{/* Breadcrumb Navigation */}
<div className="flex items-center gap-2 text-xs sm:text-sm overflow-x-auto">
<Home className="h-4 w-4 text-muted-foreground" />
{getBreadcrumbs(currentPath).map((crumb, index) => (
<div key={index} className="flex items-center gap-2">
{index > 0 && <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<button
onClick={() => onNavigateToFolder(crumb.path)}
className={
index === getBreadcrumbs(currentPath).length - 1
? 'font-medium'
: 'text-muted-foreground hover:text-foreground'
}
>
{crumb.label}
</button>
</div>
))}
</div>
{/* Toolbar */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative flex-1 max-w-full sm:max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search objects..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{selectedFileKeys.size > 0 && (
<Button
onClick={handleBulkDeleteFiles}
title={`Delete ${selectedFileKeys.size} selected file(s)`}
className="bg-transparent border border-red-500 text-red-500 hover:bg-red-500/5"
>
<Trash className="h-4 w-4" />
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
</Button>
)}
<Button variant="secondary" onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">Upload</span>
</Button>
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
<FolderPlus className="h-4 w-4" />
<span className="hidden sm:inline">Add Directory</span>
</Button>
<Button variant="outline" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* Upload Zone */}
{showUploadZone && (
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
<div className="flex gap-6">
<div className="flex-shrink-0 flex items-center justify-center">
<div className="w-20 h-20 bg-primary/10 rounded-lg flex items-center justify-center">
<svg
className="w-12 h-12 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</div>
</div>
<div className="flex-1 space-y-3">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragActive
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
}`}
>
<input {...getInputProps()} />
<p className="text-sm">
Drag and drop files/folders or{' '}
<label
htmlFor="file-input"
className="font-medium text-primary hover:underline cursor-pointer"
>
select files
</label>
{' / '}
<label
htmlFor="folder-input"
className="font-medium text-primary hover:underline cursor-pointer"
>
select folder
</label>
</p>
<input
id="file-input"
type="file"
multiple
onChange={(e) => {
if (e.target.files) {
const files = Array.from(e.target.files);
uploadFiles(files);
e.target.value = '';
}
}}
style={{ display: 'none' }}
/>
<input
id="folder-input"
type="file"
{...({ webkitdirectory: '', directory: '', mozdirectory: '' } as any)}
onChange={(e) => {
if (e.target.files) {
const files = Array.from(e.target.files);
uploadFiles(files);
e.target.value = '';
}
}}
style={{ display: 'none' }}
/>
</div>
</div>
</div>
</div>
)}
{/* Objects Table with Drag & Drop */}
<div
{...getRootProps()}
className={`relative border rounded-lg transition-all duration-200 overflow-visible ${
isDragActive
? 'border-primary bg-primary/5 border-2 shadow-lg'
: 'border-border'
}`}
>
<input {...getInputProps()} />
{/* Drag & Drop Overlay */}
{isDragActive && (
<div className="absolute inset-0 z-50 bg-primary/10 backdrop-blur-sm rounded-lg flex items-center justify-center pointer-events-none">
<div className="bg-background/95 border-2 border-primary border-dashed rounded-lg p-8 shadow-xl">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<Upload className="h-16 w-16 text-primary animate-bounce" />
<div className="absolute inset-0 h-16 w-16 text-primary opacity-30 animate-ping">
<Upload className="h-16 w-16" />
</div>
</div>
<div className="text-center space-y-2">
<p className="text-lg font-semibold text-primary">Drop files here to upload</p>
<p className="text-sm text-muted-foreground">Files will be uploaded to {currentPath || 'root'}</p>
</div>
</div>
</div>
</div>
)}
<ObjectsTable
objects={objects}
currentPath={currentPath}
searchQuery={searchQuery}
selectedFileKeys={selectedFileKeys}
isDragActive={isDragActive}
isLoading={isLoading && !isRefreshing && !isNavigating}
isTruncated={isTruncated}
nextContinuationToken={nextContinuationToken}
itemsPerPage={itemsPerPage}
onNavigateToFolder={onNavigateToFolder}
onDeleteObject={(obj) => {
setSelectedObject(obj);
setDeleteObjectDialogOpen(true);
}}
onToggleFileSelection={handleToggleFileSelection}
onSelectAllFiles={handleSelectAllFiles}
onPageChange={onPageChange}
onItemsPerPageChange={onItemsPerPageChange}
initialPageToken={initialPageToken}
initialItemsPerPage={initialItemsPerPage}
/>
</div>
</div>
{/* Create Directory Dialog */}
<CreateDirectoryDialog
open={createDirDialogOpen}
onOpenChange={setCreateDirDialogOpen}
currentPath={currentPath}
onCreateDirectory={onCreateDirectory}
/>
{/* Delete Object Dialog */}
<DeleteObjectDialog
open={deleteObjectDialogOpen}
onOpenChange={setDeleteObjectDialogOpen}
object={selectedObject}
onDeleteObject={handleDeleteObject}
/>
</div>
);
}
@@ -1,424 +0,0 @@
import {useEffect, useState} from 'react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Checkbox} from '@/components/ui/checkbox';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {ChevronLeft, ChevronRight, Download, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
import {Select, SelectOption} from '@/components/ui/select';
import {formatBytes} from '@/lib/utils';
import {formatRelativeTime, getFileType} from '@/lib/file-utils';
import type {S3Object} from '@/types';
interface ObjectsTableProps {
objects: S3Object[];
currentPath: string;
searchQuery: string;
selectedFileKeys: Set<string>;
isDragActive: boolean;
isLoading?: boolean;
isTruncated?: boolean;
nextContinuationToken?: string;
itemsPerPage: number;
onNavigateToFolder: (key: string) => void;
onDeleteObject: (object: S3Object) => void;
onToggleFileSelection: (key: string) => void;
onSelectAllFiles: () => void;
onPageChange: (token?: string) => void;
onItemsPerPageChange: (count: number) => void;
initialPageToken?: string;
initialItemsPerPage?: number;
}
type SortColumn = 'name' | 'size' | 'modified';
type SortDirection = 'asc' | 'desc';
export function ObjectsTable({
objects,
currentPath,
searchQuery,
selectedFileKeys,
isDragActive,
isLoading = false,
isTruncated = false,
nextContinuationToken,
itemsPerPage,
onNavigateToFolder,
onDeleteObject,
onToggleFileSelection,
onSelectAllFiles,
onPageChange,
onItemsPerPageChange,
initialPageToken,
initialItemsPerPage,
}: ObjectsTableProps) {
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
const [pageTokens, setPageTokens] = useState<(string | undefined)[]>([undefined]);
const [currentPageIndex, setCurrentPageIndex] = useState(0);
const [initialized, setInitialized] = useState(false);
// Initialize from URL params on first load
useEffect(() => {
if (!initialized && initialItemsPerPage && initialItemsPerPage !== itemsPerPage) {
onItemsPerPageChange(initialItemsPerPage);
setInitialized(true);
}
if (!initialized && initialPageToken && initialPageToken !== nextContinuationToken) {
// If we have an initial page token, trigger page change
onPageChange(initialPageToken);
setInitialized(true);
}
if (!initialized && !initialPageToken && !initialItemsPerPage) {
setInitialized(true);
}
}, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
const sortObjects = (objList: S3Object[]): S3Object[] => {
const sorted = [...objList].sort((a, b) => {
// Always put folders before files
const aIsFolder = a.isFolder ? 1 : 0;
const bIsFolder = b.isFolder ? 1 : 0;
if (aIsFolder !== bIsFolder) {
return bIsFolder - aIsFolder;
}
let compareValue = 0;
switch (sortColumn) {
case 'name': {
const aName = a.key.replace(currentPath, '').replace('/', '').toLowerCase();
const bName = b.key.replace(currentPath, '').replace('/', '').toLowerCase();
compareValue = aName.localeCompare(bName);
break;
}
case 'size':
compareValue = a.size - b.size;
break;
case 'modified': {
const aDate = new Date(a.lastModified).getTime();
const bDate = new Date(b.lastModified).getTime();
compareValue = aDate - bDate;
break;
}
}
return sortDirection === 'asc' ? compareValue : -compareValue;
});
return sorted;
};
useEffect(() => {
const filtered = objects.filter((obj) =>
obj.key.toLowerCase().includes(searchQuery.toLowerCase())
);
const sorted = sortObjects(filtered);
setFilteredObjects(sorted);
// Reset pagination when path/search changes
setPageTokens([undefined]);
setCurrentPageIndex(0);
}, [searchQuery, objects, sortColumn, sortDirection, currentPath]);
// Update page tokens when we get a new next token
useEffect(() => {
if (nextContinuationToken && isTruncated) {
setPageTokens(prev => {
const newTokens = [...prev];
// Only add the token if we don't have it yet
const nextIndex = currentPageIndex + 1;
if (nextIndex >= newTokens.length) {
newTokens[nextIndex] = nextContinuationToken;
}
return newTokens;
});
}
}, [nextContinuationToken, isTruncated, currentPageIndex]);
const hasPrevious = currentPageIndex > 0;
const hasNext = isTruncated;
const handleNextPage = () => {
if (hasNext && nextContinuationToken) {
const nextIndex = currentPageIndex + 1;
setCurrentPageIndex(nextIndex);
onPageChange(nextContinuationToken);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handlePreviousPage = () => {
if (hasPrevious) {
const prevIndex = currentPageIndex - 1;
setCurrentPageIndex(prevIndex);
const previousToken = pageTokens[prevIndex];
onPageChange(previousToken);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handleItemsPerPageChange = (value: string) => {
onItemsPerPageChange(Number(value));
setPageTokens([undefined]); // Reset to first page
setCurrentPageIndex(0);
};
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
return (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
}
onCheckedChange={onSelectAllFiles}
aria-label="Select all files"
/>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('name')}
>
Objects {sortColumn === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead className="hidden sm:table-cell">Type</TableHead>
<TableHead className="hidden md:table-cell">Storage Class</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('size')}
>
Size {sortColumn === 'size' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('modified')}
>
Modified {sortColumn === 'modified' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading objects...</span>
</div>
</TableCell>
</TableRow>
) : filteredObjects.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
{searchQuery
? 'No objects found matching your search'
: isDragActive
? 'Drop files or folders here'
: 'No objects in this location'}
</TableCell>
</TableRow>
) : (
filteredObjects.map((obj) => (
<TableRow key={obj.key}>
<TableCell className="w-[50px]">
{obj.isFolder ? (
<Checkbox
disabled
checked={false}
className="opacity-50 cursor-not-allowed bg-muted"
aria-label="Folders cannot be selected"
/>
) : (
<Checkbox
checked={selectedFileKeys.has(obj.key)}
onCheckedChange={() => onToggleFileSelection(obj.key)}
aria-label={`Select file ${obj.key}`}
/>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{obj.isFolder ? (
<FolderIcon className="h-4 w-4 text-muted-foreground" />
) : (
<FileIcon className="h-4 w-4 text-muted-foreground" />
)}
{obj.isFolder ? (
<button
onClick={() => onNavigateToFolder(obj.key)}
className="font-medium cursor-pointer underline hover:text-primary"
>
{obj.key.replace(currentPath, '').replace('/', '')}
</button>
) : (
<span className="font-medium">
{obj.key.replace(currentPath, '')}
</span>
)}
</div>
</TableCell>
<TableCell className="hidden sm:table-cell">
{obj.isFolder ? 'Directory' : getFileType(obj.key.replace(currentPath, ''))}
</TableCell>
<TableCell className="hidden md:table-cell">
{obj.storageClass && (
<Badge variant="secondary">{obj.storageClass}</Badge>
)}
</TableCell>
<TableCell>{obj.isFolder ? null : formatBytes(obj.size)}</TableCell>
<TableCell>
{obj.lastModified ?
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors">
{new Date(obj.lastModified).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
})} {new Date(obj.lastModified).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})} CET
</div>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1 min-w-max">
<div className="flex gap-3 items-center">
<span className="text-sm text-gray-400 w-20 text-right">UTC</span>
<span className="text-sm text-white">
{new Date(obj.lastModified).toLocaleString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: 'UTC',
})} UTC
</span>
</div>
<div className="flex gap-3 items-center">
<span className="text-sm text-gray-400 w-20 text-right">Relative</span>
<span className="text-sm text-white">
{formatRelativeTime(new Date(obj.lastModified))}
</span>
</div>
<div className="flex gap-3 items-center">
<span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
<span className="text-sm text-white font-mono">
{new Date(obj.lastModified).toISOString()}
</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>: null}
</TableCell>
<TableCell>
{!obj.isFolder && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon" className="-m-6 top-1 relative">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Download className="h-4 w-4" />
Download
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteObject(obj)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{(filteredObjects.length > 0 || hasPrevious) && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-4 border-t bg-background">
{/* Items per page selector */}
<div className="flex items-center gap-2 text-sm relative z-10">
<span className="text-muted-foreground">Items per page:</span>
<Select value={itemsPerPage.toString()} onChange={handleItemsPerPageChange}>
<SelectOption value="10">10</SelectOption>
<SelectOption value="25">25</SelectOption>
<SelectOption value="50">50</SelectOption>
<SelectOption value="100">100</SelectOption>
<SelectOption value="200">200</SelectOption>
</Select>
</div>
{/* Pagination info and controls */}
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
Page {currentPageIndex + 1} Showing {filteredObjects.length} item{filteredObjects.length !== 1 ? 's' : ''}
</span>
<div className="flex items-center gap-2">
<Button
variant={hasPrevious ? "default": "default_disabled"}
size="sm"
onClick={handlePreviousPage}
disabled={!hasPrevious}
className="h-8"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<Button
variant={hasNext ? "default": "default_disabled"}
size="sm"
onClick={handleNextPage}
disabled={!hasNext}
className="h-8"
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</div>
)}
</>
);
}
-8
View File
@@ -1,8 +0,0 @@
export { BucketListView } from './BucketListView';
export { ObjectBrowserView } from './ObjectBrowserView';
export { ObjectsTable } from './ObjectsTable';
export { CreateBucketDialog } from './CreateBucketDialog';
export { DeleteBucketDialog } from './DeleteBucketDialog';
export { BucketSettingsDialog } from './BucketSettingsDialog';
export { CreateDirectoryDialog } from './CreateDirectoryDialog';
export { DeleteObjectDialog } from './DeleteObjectDialog';
@@ -1,65 +0,0 @@
import {useEffect, useState} from 'react';
import {Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip} from 'recharts';
import type {BucketUsage} from '@/types';
import {formatBytes} from '@/lib/utils';
import {chartColorPalette, getTextColor, getTooltipStyle} from '@/lib/chart-colors';
interface BucketUsageChartProps {
data: BucketUsage[];
}
export function BucketUsageChart({ data }: BucketUsageChartProps) {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
// Check if dark mode is enabled
const checkDarkMode = () => {
setIsDark(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
// Listen for theme changes
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
const colors = isDark ? chartColorPalette.dark : chartColorPalette.light;
const textColor = getTextColor(isDark);
const tooltipStyle = getTooltipStyle(isDark);
const chartData = data.map(item => ({
name: item.bucketName,
value: item.size,
displaySize: formatBytes(item.size),
}));
return (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name }) => `${name}`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
))}
</Pie>
<Tooltip
formatter={(value) => formatBytes(value as number)}
contentStyle={tooltipStyle as React.CSSProperties}
labelStyle={{ color: textColor }}
/>
<Legend wrapperStyle={{ color: textColor }} />
</PieChart>
</ResponsiveContainer>
);
}
@@ -1,66 +0,0 @@
import {useEffect, useState} from 'react';
import {Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import type {ClusterHealth} from '@/types';
import {getGridColor, getTextColor, getTooltipStyle, grafanaColors} from '@/lib/chart-colors';
interface ClusterHealthChartProps {
data: ClusterHealth;
}
export function ClusterHealthChart({ data }: ClusterHealthChartProps) {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const checkDarkMode = () => {
setIsDark(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
const colors = isDark ? grafanaColors.dark : grafanaColors.light;
const textColor = getTextColor(isDark);
const gridColor = getGridColor(isDark);
const tooltipStyle = getTooltipStyle(isDark);
const unhealthyColor = isDark ? '#e8e8e8' : '#d1d5db';
const chartData = [
{
metric: 'Nodes',
healthy: data.storageNodesUp,
unhealthy: data.storageNodes - data.storageNodesUp,
},
{
metric: 'Partitions',
healthy: data.partitionsAllOk,
unhealthy: data.partitions - data.partitionsAllOk,
},
{
metric: 'Connected',
healthy: data.connectedNodes,
unhealthy: data.knownNodes - data.connectedNodes,
},
];
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
<XAxis dataKey="metric" stroke={textColor} />
<YAxis stroke={textColor} />
<Tooltip
contentStyle={tooltipStyle as React.CSSProperties}
labelStyle={{ color: textColor }}
/>
<Legend wrapperStyle={{ color: textColor }} />
<Bar dataKey="healthy" stackId="a" fill={colors.green} name="Healthy" />
<Bar dataKey="unhealthy" stackId="a" fill={unhealthyColor} name="Unhealthy" />
</BarChart>
</ResponsiveContainer>
);
}
@@ -1,60 +0,0 @@
import { useEffect, useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import type { RequestMetrics } from '@/types';
import { grafanaColors, getTextColor, getGridColor, getTooltipStyle } from '@/lib/chart-colors';
interface RequestMetricsChartProps {
data: RequestMetrics;
}
export function RequestMetricsChart({ data }: RequestMetricsChartProps) {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const checkDarkMode = () => {
setIsDark(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
const colors = isDark ? grafanaColors.dark : grafanaColors.light;
const textColor = getTextColor(isDark);
const gridColor = getGridColor(isDark);
const tooltipStyle = getTooltipStyle(isDark);
const chartData = [
{
name: 'Requests (24h)',
GET: data.getRequests,
PUT: data.putRequests,
DELETE: data.deleteRequests,
LIST: data.listRequests,
},
];
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
<XAxis dataKey="name" stroke={textColor} />
<YAxis stroke={textColor} />
<Tooltip
formatter={(value) => (value as number).toLocaleString()}
contentStyle={tooltipStyle as React.CSSProperties}
labelStyle={{ color: textColor }}
/>
<Legend wrapperStyle={{ color: textColor }} />
<Bar dataKey="GET" stackId="a" fill={colors.blue} />
<Bar dataKey="PUT" stackId="a" fill={colors.green} />
<Bar dataKey="DELETE" stackId="a" fill={colors.red} />
<Bar dataKey="LIST" stackId="a" fill={colors.orange} />
</BarChart>
</ResponsiveContainer>
);
}
-30
View File
@@ -1,30 +0,0 @@
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { ThemeToggle } from './theme-toggle';
interface HeaderProps {
title: string;
}
export function Header({ title }: HeaderProps) {
return (
<header className="sticky top-0 z-40 border-b" style={{ backgroundColor: 'var(--background)' }}>
<div className="flex h-16 items-center gap-2 sm:gap-4 px-4 sm:px-6 md:pl-6">
<div className="flex-1 min-w-0 md:ml-0 ml-12">
<h1 className="text-lg sm:text-xl md:text-2xl font-semibold truncate">{title}</h1>
</div>
<div className="flex items-center gap-2">
<div className="relative hidden sm:block w-32 md:w-64">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search..."
className="pl-8 w-full"
/>
</div>
<ThemeToggle />
</div>
</div>
</header>
);
}
-36
View File
@@ -1,36 +0,0 @@
import { Outlet } from 'react-router-dom';
import { Sidebar } from './sidebar';
import { useState } from 'react';
import { Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden">
{/* Mobile menu button */}
<Button
variant="ghost"
size="icon"
className="fixed top-4 left-4 z-50 md:hidden"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
<Menu className="h-6 w-6" />
</Button>
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
);
}
@@ -1,92 +0,0 @@
import {Link, useLocation} from 'react-router-dom';
import {cn} from '@/lib/utils';
import {Database, Key, LayoutDashboard, Server} from 'lucide-react';
interface NavItem {
title: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
{
title: 'Dashboard',
href: '/',
icon: LayoutDashboard,
},
{
title: 'Buckets',
href: '/buckets',
icon: Database,
},
{
title: 'Cluster',
href: '/cluster',
icon: Server,
},
{
title: 'Access Control',
href: '/access',
icon: Key,
},
];
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
}
export function Sidebar({ isOpen, onClose }: SidebarProps) {
const location = useLocation();
return (
<div
className={cn(
'flex h-full w-64 flex-col border-r transition-transform duration-300 ease-in-out md:translate-x-0',
'fixed md:static z-50',
isOpen ? 'translate-x-0' : '-translate-x-full'
)}
style={{ backgroundColor: 'var(--background)' }}
>
<div className="flex h-16 items-center border-b px-6">
<img src="/garage.png" alt="Garage UI Logo" className="h-8 w-8 mr-2" />
<span className="text-lg font-semibold">Garage UI</span>
</div>
<nav className="flex-1 space-y-1 p-4">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.href;
return (
<Link
key={item.href}
to={item.href}
onClick={onClose}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary shadow-sm'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
style={isActive ? { backgroundColor: 'var(--primary)', color: '#000000' } : undefined}
>
<Icon className="h-5 w-5" />
{item.title}
</Link>
);
})}
</nav>
{/*<div className="border-t p-4">*/}
{/* <div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">*/}
{/* <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">*/}
{/* AD*/}
{/* </div>*/}
{/* <div className="flex-1 overflow-hidden">*/}
{/* <p className="text-sm font-medium truncate">Admin User</p>*/}
{/* <p className="text-xs text-muted-foreground truncate">admin@garage.local</p>*/}
{/* </div>*/}
{/* </div>*/}
{/*</div>*/}
</div>
);
}
@@ -1,30 +0,0 @@
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useTheme } from '@/components/theme-provider';
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,71 +0,0 @@
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'system';
interface ThemeProviderProps {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}
interface ThemeProviderState {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'garage-ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
return context;
};
-32
View File
@@ -1,32 +0,0 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
-46
View File
@@ -1,46 +0,0 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none',
{
variants: {
variant: {
default: 'bg-[#ff9329] text-black hover:bg-[#e58625] cursor-pointer',
default_disabled: 'bg-[#ff9329] text-black opacity-50 cursor-not-allowed',
secondary: 'border border-[#ff9329] text-[#ff9329] cursor-pointer',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive cursor-pointer',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground cursor-pointer',
outline_disabled: 'border border-input bg-background text-muted-foreground opacity-50 cursor-not-allowed',
ghost: 'hover:bg-accent hover:text-accent-foreground cursor-pointer',
link: 'text-primary underline-offset-4 hover:underline cursor-pointer',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
-55
View File
@@ -1,55 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
-36
View File
@@ -1,36 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface CheckboxProps
extends React.InputHTMLAttributes<HTMLInputElement> {
onCheckedChange?: (checked: boolean) => void;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, onCheckedChange, onChange, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onCheckedChange) {
onCheckedChange(e.target.checked);
}
if (onChange) {
onChange(e);
}
};
return (
<input
type="checkbox"
className={cn(
'h-4 w-4 shrink-0 border border-primary rounded cursor-pointer ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary',
className
)}
ref={ref}
onChange={handleChange}
{...props}
/>
);
}
);
Checkbox.displayName = 'Checkbox';
export { Checkbox };
-157
View File
@@ -1,157 +0,0 @@
import * as React from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface DialogContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const DialogContext = React.createContext<DialogContextValue | undefined>(undefined);
function useDialog() {
const context = React.useContext(DialogContext);
if (!context) {
throw new Error('useDialog must be used within a Dialog');
}
return context;
}
interface DialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}
const Dialog: React.FC<DialogProps> = ({ open = false, onOpenChange, children }) => {
return (
<DialogContext.Provider value={{ open, onOpenChange: onOpenChange || (() => {}) }}>
{children}
</DialogContext.Provider>
);
};
const DialogTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ onClick, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<button
ref={ref}
onClick={(e) => {
onOpenChange(true);
onClick?.(e);
}}
{...props}
/>
);
});
DialogTrigger.displayName = 'DialogTrigger';
const DialogPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { open } = useDialog();
if (!open) return null;
return <>{children}</>;
};
const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<div
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
onClick={() => onOpenChange(false)}
{...props}
/>
);
}
);
DialogOverlay.displayName = 'DialogOverlay';
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<DialogPortal>
<DialogOverlay />
<div className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] sm:w-full max-w-lg">
<div
ref={ref}
style={{ backgroundColor: 'var(--background)' }}
className={cn(
'relative p-4 sm:p-6 shadow-lg duration-200 rounded-lg border',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className
)}
{...props}
>
{children}
<button
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 rounded-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
</div>
</DialogPortal>
);
}
);
DialogContent.displayName = 'DialogContent';
const DialogHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left bg-background', className)} {...props} />;
DialogHeader.displayName = 'DialogHeader';
const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end space-y-2 space-y-reverse sm:space-y-0 sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
DialogTitle.displayName = 'DialogTitle';
const DialogDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-xs text-muted-foreground', className)} {...props} />
));
DialogDescription.displayName = 'DialogDescription';
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
@@ -1,181 +0,0 @@
import * as React from 'react';
import {createPortal} from 'react-dom';
import {cn} from '@/lib/utils';
interface DropdownMenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<HTMLButtonElement | null>;
}
const DropdownMenuContext = React.createContext<DropdownMenuContextValue | undefined>(undefined);
function useDropdownMenu() {
const context = React.useContext(DropdownMenuContext);
if (!context) {
throw new Error('useDropdownMenu must be used within a DropdownMenu');
}
return context;
}
interface DropdownMenuProps {
children: React.ReactNode;
}
const DropdownMenu: React.FC<DropdownMenuProps> = ({ children }) => {
const [open, setOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement>(null);
return (
<DropdownMenuContext.Provider value={{ open, setOpen, triggerRef }}>
<div className="relative inline-block text-left">{children}</div>
</DropdownMenuContext.Provider>
);
};
const DropdownMenuTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ onClick, ...props }, ref) => {
const { open, setOpen, triggerRef } = useDropdownMenu();
// Merge the forwarded ref with the context triggerRef
React.useImperativeHandle(ref, () => triggerRef.current as HTMLButtonElement);
return (
<button
ref={triggerRef}
onClick={(e) => {
setOpen(!open);
onClick?.(e);
}}
{...props}
/>
);
});
DropdownMenuTrigger.displayName = 'DropdownMenuTrigger';
interface DropdownMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {
align?: 'start' | 'end' | 'center';
}
const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContentProps>(
({ className, children, align = 'start', ...props }) => {
const { open, setOpen, triggerRef } = useDropdownMenu();
const contentRef = React.useRef<HTMLDivElement>(null);
const [position, setPosition] = React.useState({ top: 0, left: 0 });
// Calculate position based on trigger element
React.useEffect(() => {
const updatePosition = () => {
if (open && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
const scrollY = window.scrollY || document.documentElement.scrollTop;
const scrollX = window.scrollX || document.documentElement.scrollLeft;
let left = rect.left + scrollX;
const top = rect.bottom + scrollY + 8; // 8px gap (mt-2)
// Adjust horizontal alignment
if (align === 'end') {
left = rect.right + scrollX - 224; // 224px = w-56
} else if (align === 'center') {
left = rect.left + scrollX + (rect.width / 2) - 112; // 112px = half of w-56
}
setPosition({ top, left });
}
};
updatePosition();
if (open) {
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
}
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [open, align, triggerRef]);
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const isClickOnTrigger = triggerRef.current?.contains(event.target as Node);
const isClickOnContent = contentRef.current?.contains(event.target as Node);
if (!isClickOnContent && !isClickOnTrigger) {
setOpen(false);
}
};
if (open) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [open, setOpen, triggerRef]);
if (!open) return null;
const content = (
<div
ref={contentRef}
style={{
backgroundColor: 'var(--popover)',
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
}}
className={cn(
'z-50 w-56 origin-top-right rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
className
)}
{...props}
>
<div className="py-1">{children}</div>
</div>
);
return createPortal(content, document.body);
}
);
DropdownMenuContent.displayName = 'DropdownMenuContent';
const DropdownMenuItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, onClick, ...props }, ref) => {
const { setOpen } = useDropdownMenu();
return (
<div
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none',
className
)}
onClick={(e) => {
onClick?.(e);
setOpen(false);
}}
{...props}
/>
);
}
);
DropdownMenuItem.displayName = 'DropdownMenuItem';
const DropdownMenuSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
)
);
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
};
-23
View File
@@ -1,23 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };
-164
View File
@@ -1,164 +0,0 @@
import * as React from 'react';
import {cn} from '@/lib/utils';
import {ChevronDown, Check} from 'lucide-react';
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps {
value?: string;
onChange?: (value: string) => void;
children?: React.ReactNode;
className?: string;
disabled?: boolean;
placeholder?: string;
}
const SelectContext = React.createContext<{
value?: string;
onChange?: (value: string) => void;
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
const useSelectContext = () => {
const context = React.useContext(SelectContext);
if (!context) {
throw new Error('Select components must be used within a Select');
}
return context;
};
const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
({ className, children, value, onChange, disabled, placeholder = 'Select an option...', ...props }, _ref) => {
const [open, setOpen] = React.useState(false);
const [internalValue, setInternalValue] = React.useState(value);
const containerRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const displayValue = React.useMemo(() => {
const currentValue = value ?? internalValue;
if (!currentValue) return placeholder;
// Extract label from children
const options = React.Children.toArray(children);
const selectedOption = options.find((child) => {
if (React.isValidElement<SelectOptionProps>(child) && child.type === SelectOption) {
return child.props.value === currentValue;
}
return false;
});
if (React.isValidElement<SelectOptionProps>(selectedOption)) {
return selectedOption.props.children;
}
return currentValue;
}, [value, internalValue, children, placeholder]);
React.useEffect(() => {
setInternalValue(value);
}, [value]);
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
if (open) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [open]);
const handleChange = (newValue: string) => {
setInternalValue(newValue);
onChange?.(newValue);
setOpen(false);
};
return (
<SelectContext.Provider value={{ value: value ?? internalValue, onChange: handleChange, open, setOpen }}>
<div ref={containerRef} className="relative">
<button
ref={buttonRef}
type="button"
className={cn(
'w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background text-foreground',
'flex items-center justify-between',
'ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
!internalValue && !value && 'text-muted-foreground',
className
)}
onClick={() => !disabled && setOpen(!open)}
disabled={disabled}
{...props}
>
<span className="truncate">{displayValue}</span>
<ChevronDown className={cn('h-4 w-4 opacity-50 transition-transform', open && 'transform rotate-180')} />
</button>
{open && (
<div
className="absolute z-50 w-full mt-1 text-popover-foreground rounded-md border border-border shadow-lg max-h-60 overflow-auto"
style={{ backgroundColor: 'var(--popover)' }}
>
{children}
</div>
)}
</div>
</SelectContext.Provider>
);
}
);
Select.displayName = 'Select';
export interface SelectOptionProps {
value: string;
children: React.ReactNode;
className?: string;
disabled?: boolean;
}
const SelectOption = React.forwardRef<HTMLDivElement, SelectOptionProps>(
({ className, children, value: optionValue, disabled, ...props }, ref) => {
const { value, onChange } = useSelectContext();
const isSelected = value === optionValue;
return (
<div
ref={ref}
className={cn(
'relative flex items-center w-full px-3 py-2 text-sm cursor-pointer select-none bg-transparent',
'transition-colors',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground',
isSelected && 'bg-accent text-accent-foreground',
disabled && 'pointer-events-none opacity-50',
className
)}
onClick={() => {
if (!disabled) {
onChange?.(optionValue);
}
}}
{...props}
>
<span className="flex-1">{children}</span>
{isSelected && <Check className="h-4 w-4 ml-2" />}
</div>
);
}
);
SelectOption.displayName = 'SelectOption';
export { Select, SelectOption };
-90
View File
@@ -1,90 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
)
);
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('border-t bg-muted font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted data-[state=selected]:bg-muted',
className
)}
{...props}
/>
)
);
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
));
TableCaption.displayName = 'TableCaption';
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
-118
View File
@@ -1,118 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface TabsContextValue {
value: string;
onValueChange: (value: string) => void;
}
const TabsContext = React.createContext<TabsContextValue | undefined>(undefined);
function useTabs() {
const context = React.useContext(TabsContext);
if (!context) {
throw new Error('useTabs must be used within a Tabs');
}
return context;
}
interface TabsProps {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
children: React.ReactNode;
className?: string;
}
const Tabs: React.FC<TabsProps> = ({
defaultValue,
value: controlledValue,
onValueChange,
children,
className,
}) => {
const [internalValue, setInternalValue] = React.useState(defaultValue || '');
const value = controlledValue !== undefined ? controlledValue : internalValue;
const handleValueChange = (newValue: string) => {
if (controlledValue === undefined) {
setInternalValue(newValue);
}
onValueChange?.(newValue);
};
return (
<TabsContext.Provider value={{ value, onValueChange: handleValueChange }}>
<div className={className}>{children}</div>
</TabsContext.Provider>
);
};
const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
)
);
TabsList.displayName = 'TabsList';
interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
}
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
({ className, value: triggerValue, onClick, ...props }, ref) => {
const { value, onValueChange } = useTabs();
const isActive = value === triggerValue;
return (
<button
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none',
isActive
? 'bg-background text-foreground shadow-sm'
: 'hover:bg-accent hover:text-accent-foreground',
className
)}
onClick={(e) => {
onValueChange(triggerValue);
onClick?.(e);
}}
{...props}
/>
);
}
);
TabsTrigger.displayName = 'TabsTrigger';
interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
}
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
({ className, value: contentValue, ...props }, ref) => {
const { value } = useTabs();
if (value !== contentValue) return null;
return (
<div
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
);
}
);
TabsContent.displayName = 'TabsContent';
export { Tabs, TabsList, TabsTrigger, TabsContent };
-27
View File
@@ -1,27 +0,0 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border border-neutral-200 bg-neutral-950 px-3 py-1.5 text-sm text-neutral-50 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50',
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
-3
View File
@@ -1,3 +0,0 @@
export { useDashboardData } from './useApi';
export { useBuckets } from './useBuckets';
export { useBucketObjects } from './useBucketObjects';
-253
View File
@@ -1,253 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { bucketsApi, objectsApi, accessApi, garageApi, analyticsApi } from '@/lib/api';
import { queryKeys } from '@/lib/query-client';
import { toast } from 'sonner';
// ===========================
// Bucket Hooks
// ===========================
export function useBuckets() {
return useQuery({
queryKey: queryKeys.buckets.list(),
queryFn: () => bucketsApi.list(),
});
}
export function useBucket(name: string, enabled = true) {
return useQuery({
queryKey: queryKeys.buckets.detail(name),
queryFn: () => bucketsApi.get(name),
enabled: enabled && !!name,
});
}
export function useCreateBucket() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, region }: { name: string; region?: string }) =>
bucketsApi.create(name, region),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
toast.success('Bucket created successfully');
},
});
}
export function useDeleteBucket() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => bucketsApi.delete(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
toast.success('Bucket deleted successfully');
},
});
}
export function useGrantBucketPermission() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ bucketName, accessKeyId, permissions }: {
bucketName: string;
accessKeyId: string;
permissions: { read: boolean; write: boolean; owner: boolean };
}) => bucketsApi.grantPermission(bucketName, accessKeyId, permissions),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucketName) });
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
toast.success('Permissions granted successfully');
},
});
}
// ===========================
// Object Hooks
// ===========================
export function useObjects(bucket: string, prefix?: string, enabled = true) {
return useQuery({
queryKey: queryKeys.objects.list(bucket, prefix),
queryFn: () => objectsApi.list(bucket, prefix),
enabled: enabled && !!bucket,
});
}
export function useUploadObject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ bucket, key, file }: { bucket: string; key: string; file: File }) =>
objectsApi.upload(bucket, key, file),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
toast.success('File uploaded successfully');
},
});
}
export function useUploadMultipleObjects() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ bucket, files }: { bucket: string; files: File[] }) =>
objectsApi.uploadMultiple(bucket, files),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
toast.success('Files uploaded successfully');
},
});
}
export function useDeleteObject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ bucket, key }: { bucket: string; key: string }) =>
objectsApi.delete(bucket, key),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
toast.success('File deleted successfully');
},
});
}
export function useDeleteMultipleObjects() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ bucket, keys, prefix }: { bucket: string; keys: string[]; prefix?: string }) =>
objectsApi.deleteMultiple(bucket, keys, prefix),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
toast.success(`${variables.keys.length} files deleted successfully`);
},
});
}
// ===========================
// Access Key Hooks
// ===========================
export function useAccessKeys() {
return useQuery({
queryKey: queryKeys.accessKeys.list(),
queryFn: () => accessApi.listKeys(),
});
}
export function useAccessKey(keyId: string, enabled = true) {
return useQuery({
queryKey: queryKeys.accessKeys.detail(keyId),
queryFn: () => accessApi.getKey(keyId),
enabled: enabled && !!keyId,
});
}
export function useCreateAccessKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, permissions }: { name: string; permissions?: any[] }) =>
accessApi.createKey(name, permissions),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
toast.success('Access key created successfully');
},
});
}
export function useDeleteAccessKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (keyId: string) => accessApi.deleteKey(keyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
toast.success('Access key deleted successfully');
},
});
}
export function useUpdateAccessKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ keyId, updates }: { keyId: string; updates: any }) =>
accessApi.updateKey(keyId, updates),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.detail(variables.keyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.list() });
toast.success('Access key updated successfully');
},
});
}
// ===========================
// Cluster Hooks
// ===========================
export function useClusterHealth() {
return useQuery({
queryKey: queryKeys.cluster.health(),
queryFn: () => garageApi.getClusterHealth(),
staleTime: 30 * 1000, // Refresh health every 30 seconds
});
}
export function useClusterStatus() {
return useQuery({
queryKey: queryKeys.cluster.status(),
queryFn: () => garageApi.getClusterStatus(),
staleTime: 60 * 1000, // Refresh status every minute
});
}
export function useClusterStatistics() {
return useQuery({
queryKey: queryKeys.cluster.statistics(),
queryFn: () => garageApi.getClusterStatistics(),
staleTime: 60 * 1000, // Refresh statistics every minute
});
}
// ===========================
// Dashboard Hooks
// ===========================
export function useDashboardMetrics() {
return useQuery({
queryKey: queryKeys.dashboard.metrics(),
queryFn: () => analyticsApi.getMetrics(),
staleTime: 2 * 60 * 1000, // Refresh dashboard every 2 minutes
});
}
// Combined hook for dashboard data
export function useDashboardData() {
const metrics = useDashboardMetrics();
const buckets = useBuckets();
const health = useClusterHealth();
return {
metrics,
buckets,
health,
isLoading: metrics.isLoading || buckets.isLoading || health.isLoading,
isError: metrics.isError || buckets.isError || health.isError,
};
}
-167
View File
@@ -1,167 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { objectsApi } from '@/lib/api';
import type { S3Object } from '@/types';
import { toast } from 'sonner';
export function useBucketObjects(bucketName: string | null, currentPath: string = '') {
const [objects, setObjects] = useState<S3Object[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isNavigating, setIsNavigating] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isTruncated, setIsTruncated] = useState(false);
const [nextContinuationToken, setNextContinuationToken] = useState<string | undefined>(undefined);
const [itemsPerPage, setItemsPerPage] = useState(25);
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
const [previousPath, setPreviousPath] = useState<string>(currentPath);
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
if (!bucketName) return;
try {
if (isRefresh) {
setIsRefreshing(true);
} else if (isNav) {
setIsNavigating(true);
} else {
setIsLoading(true);
}
setError(null);
const response = await objectsApi.list(bucketName, currentPath, itemsPerPage, continuationToken);
setObjects(response.objects);
setIsTruncated(response.isTruncated);
setNextContinuationToken(response.nextContinuationToken);
setCurrentContinuationToken(continuationToken);
} catch (err) {
setError(err as Error);
console.error('Failed to fetch objects:', err);
} finally {
setIsLoading(false);
setIsRefreshing(false);
setIsNavigating(false);
}
}, [bucketName, currentPath, itemsPerPage]);
useEffect(() => {
if (!bucketName) return;
// Detect if this is a path change (navigation) or initial load
const isPathChange = previousPath !== currentPath && objects.length > 0;
setPreviousPath(currentPath);
// Use navigation mode if it's a path change, otherwise use normal loading
fetchObjects(undefined, false, isPathChange);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bucketName, currentPath, itemsPerPage]);
const uploadFiles = useCallback(async (files: File[]) => {
if (!bucketName) return false;
try {
// Check if files are from a folder upload
const hasRelativePaths = files.some((file: any) => file.webkitRelativePath);
// Get unique folders from the files
const folders = new Set<string>();
files.forEach((file: any) => {
if (file.webkitRelativePath) {
const parts = file.webkitRelativePath.split('/');
if (parts.length > 1) {
folders.add(parts[0]);
}
}
});
for (const file of files) {
// Use webkitRelativePath if available (for folder uploads), otherwise use file.name
const relativePath = (file as any).webkitRelativePath || file.name;
const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
await objectsApi.upload(bucketName, key, file);
}
if (hasRelativePaths && folders.size > 0) {
const folderNames = Array.from(folders).join(', ');
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''} from ${folders.size} folder${folders.size > 1 ? 's' : ''} (${folderNames})`);
} else {
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''}`);
}
await fetchObjects(currentContinuationToken, true);
return true;
} catch (error) {
console.error('Upload error:', error);
return false;
}
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
const deleteObject = useCallback(async (key: string) => {
if (!bucketName) return false;
try {
// Optimistically remove the object from the UI
setObjects(prev => prev.filter(obj => obj.key !== key));
await objectsApi.delete(bucketName, key);
toast.success(`Object "${key}" deleted successfully`);
await fetchObjects(currentContinuationToken, true);
return true;
} catch (error) {
console.error('Delete object error:', error);
// Revert the optimistic update by refetching
await fetchObjects(currentContinuationToken, true);
return false;
}
}, [bucketName, currentContinuationToken, fetchObjects]);
const deleteMultipleObjects = useCallback(async (keys: string[]) => {
if (!bucketName || keys.length === 0) return false;
try {
// Optimistically remove the objects from the UI
setObjects(prev => prev.filter(obj => !keys.includes(obj.key)));
await objectsApi.deleteMultiple(bucketName, keys, currentPath || undefined);
toast.success(`Successfully deleted ${keys.length} file${keys.length > 1 ? 's' : ''}`);
await fetchObjects(currentContinuationToken, true);
return true;
} catch (error) {
console.error('Bulk delete error:', error);
// Revert the optimistic update by refetching
await fetchObjects(currentContinuationToken, true);
return false;
}
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
const createDirectory = useCallback(async (dirName: string) => {
if (!bucketName) return false;
try {
const dirKey = currentPath ? `${currentPath}${dirName}/` : `${dirName}/`;
await objectsApi.upload(bucketName, dirKey, new File([], ''));
toast.success(`Directory "${dirName}" created successfully`);
await fetchObjects(currentContinuationToken, true);
return true;
} catch (error) {
console.error('Create directory error:', error);
return false;
}
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
return {
objects,
isLoading,
isRefreshing,
isNavigating,
error,
isTruncated,
nextContinuationToken,
currentContinuationToken,
itemsPerPage,
setItemsPerPage,
fetchObjects,
uploadFiles,
deleteObject,
deleteMultipleObjects,
createDirectory,
};
}
-77
View File
@@ -1,77 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { bucketsApi } from '@/lib/api';
import type { Bucket } from '@/types';
import { toast } from 'sonner';
export function useBuckets() {
const [buckets, setBuckets] = useState<Bucket[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchBuckets = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const data = await bucketsApi.list();
setBuckets(data);
} catch (err) {
setError(err as Error);
console.error('Failed to fetch buckets:', err);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchBuckets();
}, [fetchBuckets]);
const createBucket = useCallback(async (name: string, region?: string) => {
try {
await bucketsApi.create(name, region);
toast.success(`Bucket "${name}" created successfully`);
await fetchBuckets();
return true;
} catch (error) {
console.error('Create bucket error:', error);
return false;
}
}, [fetchBuckets]);
const deleteBucket = useCallback(async (name: string) => {
try {
await bucketsApi.delete(name);
toast.success(`Bucket "${name}" deleted successfully`);
await fetchBuckets();
return true;
} catch (error) {
console.error('Delete bucket error:', error);
return false;
}
}, [fetchBuckets]);
const grantPermission = useCallback(async (
bucketName: string,
accessKeyId: string,
permissions: { read: boolean; write: boolean; owner: boolean }
) => {
try {
await bucketsApi.grantPermission(bucketName, accessKeyId, permissions);
toast.success('Permissions granted successfully');
return true;
} catch (error) {
console.error('Grant permission error:', error);
return false;
}
}, []);
return {
buckets,
isLoading,
error,
fetchBuckets,
createBucket,
deleteBucket,
grantPermission,
};
}
-104
View File
@@ -1,104 +0,0 @@
@import 'tailwindcss';
@layer base {
:root {
/* Light Theme Colors */
--background: #ffffff;
--foreground: #0a0a0f;
--card: #ffffff;
--card-foreground: #0a0a0f;
--popover: #fafafa;
--popover-foreground: #0a0a0f;
--primary: #ff9447;
--primary-foreground: #ffffff;
--secondary: #f4f4f5;
--secondary-foreground: #18181b;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #18181b;
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #ff9447;
--radius: 0.5rem;
/* Grafana Chart Colors */
--chart-blue: #1f77b4;
--chart-orange: #ff7f0e;
--chart-green: #2ca02c;
--chart-red: #d62728;
--chart-purple: #9467bd;
--chart-brown: #8c564b;
--chart-pink: #e377c2;
--chart-gray: #7f7f7f;
--chart-olive: #bcbd22;
--chart-cyan: #17becf;
}
.dark {
/* Dark Theme Colors - VS Code Inspired */
--background: #1a1d29;
--foreground: #e8eaed;
--card: #252834;
--card-foreground: #e8eaed;
--popover: #2d3142;
--popover-foreground: #e8eaed;
--primary: #ff9447;
--primary-foreground: #ffffff;
--secondary: #3a3f52;
--secondary-foreground: #e8eaed;
--muted: #4a5064;
--muted-foreground: #a0a4b8;
--accent: #3a3f52;
--accent-foreground: #e8eaed;
--destructive: #ef5350;
--destructive-foreground: #e8eaed;
--border: #3a3f52;
--input: #3a3f52;
--ring: #ff9447;
/* Grafana Chart Colors */
--chart-blue: #3eb0ff;
--chart-orange: #ff9830;
--chart-green: #73bf69;
--chart-red: #f2495c;
--chart-purple: #b581d8;
--chart-brown: #c4a2e0;
--chart-pink: #ff9d96;
--chart-gray: #9ba3af;
--chart-olive: #fac858;
--chart-cyan: #37b7c3;
}
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
font-feature-settings: 'rlig' 1, 'calt' 1;
}
}
@layer utilities {
.scrollbar-thin::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted-foreground) 20%, transparent);
border-radius: 0.375rem;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--muted-foreground) 30%, transparent);
}
}
-312
View File
@@ -1,312 +0,0 @@
import axios from 'axios';
import {toast} from 'sonner';
import type {
AccessKey,
Bucket,
BucketDetails,
ClusterHealth,
ClusterStatistics,
ClusterStatus,
GarageMetrics,
MultiNodeResponse,
MultiNodeStatisticsResponse,
ObjectListResponse,
ObjectMetadata,
S3Object,
StorageMetrics,
} from '@/types';
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth-token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => {
// If response has success=false in data, treat it as an error
if (response.data && response.data.success === false && response.data.error) {
const error = response.data.error;
const errorMessage = error.message || 'An error occurred';
const errorCode = error.code || 'UNKNOWN_ERROR';
// Display toast with error details
toast.error(errorMessage, {
description: `Error Code: ${errorCode}`,
});
// Reject the promise so it's treated as an error
return Promise.reject(new Error(errorMessage));
}
return response;
},
(error) => {
// Handle axios errors
if (error.response) {
// Server responded with error status
const data = error.response.data;
if (data && data.error) {
const errorMessage = data.error.message || 'An error occurred';
const errorCode = data.error.code || 'UNKNOWN_ERROR';
toast.error(errorMessage, {
description: `Error Code: ${errorCode}`,
});
} else {
// Generic HTTP error
toast.error(`Request failed: ${error.response.status}`, {
description: error.response.statusText || 'Unknown error',
});
}
} else if (error.request) {
// Request made but no response received
toast.error('Network Error', {
description: 'Unable to reach the server. Please check your connection.',
});
} else {
// Something else happened
toast.error('Error', {
description: error.message || 'An unexpected error occurred',
});
}
return Promise.reject(error);
}
);
// Bucket API
export const bucketsApi = {
list: async (): Promise<Bucket[]> => {
const response = await api.get('/v1/buckets');
return response.data.data.buckets || [];
},
get: async (name: string): Promise<BucketDetails> => {
const response = await api.get(`/v1/buckets/${name}`);
return response.data.data;
},
create: async (bucketName: string, bucketRegion?: string): Promise<void> => {
await api.post('/v1/buckets', { name: bucketName, region: bucketRegion });
},
delete: async (name: string): Promise<void> => {
await api.delete(`/v1/buckets/${name}`);
},
grantPermission: async (
bucketName: string,
accessKeyId: string,
permissions: { read: boolean; write: boolean; owner: boolean }
): Promise<void> => {
await api.post(`/v1/buckets/${bucketName}/permissions`, {
accessKeyId,
permissions,
});
},
updateSettings: async (name: string, settings: Partial<BucketDetails>): Promise<void> => {
await api.patch(`/v1/buckets/${name}/settings`, settings);
},
};
// Objects API
export const objectsApi = {
list: async (bucket: string, prefix?: string, maxKeys?: number, continuationToken?: string): Promise<ObjectListResponse> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const params: any = {};
if (prefix) params.prefix = prefix;
if (maxKeys) params.max_keys = maxKeys;
if (continuationToken) params.continuation_token = continuationToken;
const response = await api.get(`/v1/buckets/${bucket}/objects`, { params });
const data = response.data.data;
// Combine objects and prefixes (folders)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const objects: S3Object[] = data.objects?.map((obj: any) => ({
key: obj.key,
size: obj.size,
lastModified: obj.last_modified,
etag: obj.etag,
storageClass: obj.storage_class,
isFolder: false,
})) || [];
const folders: S3Object[] = data.prefixes?.map((prefix: string) => ({
key: prefix,
size: 0,
lastModified: null,
isFolder: true,
})) || [];
return {
bucket: data.bucket,
objects: [...folders, ...objects],
prefixes: data.prefixes || [],
count: data.count,
isTruncated: data.is_truncated || false,
nextContinuationToken: data.next_continuation_token,
};
},
get: async (bucket: string, key: string): Promise<Blob> => {
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`, {
responseType: 'blob'
});
return response.data;
},
getMetadata: async (bucket: string, key: string): Promise<ObjectMetadata> => {
const response = await api.head(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
return response.data.data;
},
upload: async (bucket: string, key: string, file: File): Promise<void> => {
const formData = new FormData();
formData.append('file', file);
formData.append('key', key);
await api.post(`/v1/buckets/${bucket}/objects`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
uploadMultiple: async (bucket: string, files: File[]): Promise<any> => {
const formData = new FormData();
files.forEach(file => {
formData.append('files', file);
});
const response = await api.post(`/v1/buckets/${bucket}/objects/upload-multiple`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data.data;
},
delete: async (bucket: string, key: string): Promise<void> => {
await api.delete(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
},
deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise<void> => {
const payload = { keys, ...(prefix && { prefix }) };
await api.post(`/v1/buckets/${bucket}/objects/delete-multiple`, payload);
},
getPresignedUrl: async (bucket: string, key: string, expiresIn: number = 3600): Promise<string> => {
const response = await api.post(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}/presign`, {}, {
params: { expires_in: expiresIn }
});
return response.data.data.url;
},
};
// Access Control API (Users/Keys)
export const accessApi = {
listKeys: async (): Promise<AccessKey[]> => {
const response = await api.get('/v1/users');
return response.data.data.users || [];
},
getKey: async (accessKey: string): Promise<AccessKey> => {
const response = await api.get(`/v1/users/${accessKey}`);
return response.data.data;
},
getSecretKey: async (accessKey: string): Promise<string> => {
const response = await api.get(`/v1/users/${accessKey}/secret`);
return response.data.data.secretKey;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createKey: async (name: string, permissions?: any[]): Promise<AccessKey> => {
const response = await api.post('/v1/users', { name, permissions });
return response.data.data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateKey: async (accessKey: string, updates: any): Promise<void> => {
await api.patch(`/v1/users/${accessKey}`, updates);
},
deleteKey: async (accessKey: string): Promise<void> => {
await api.delete(`/v1/users/${accessKey}`);
},
};
// Analytics API
export const analyticsApi = {
getMetrics: async (): Promise<StorageMetrics> => {
const response = await api.get('/v1/monitoring/dashboard');
return response.data.data;
},
};
// Garage Cluster & Monitoring API
export const garageApi = {
getClusterHealth: async (): Promise<ClusterHealth> => {
const response = await api.get('/v1/cluster/health');
return response.data.data;
},
getClusterStatus: async (): Promise<ClusterStatus> => {
const response = await api.get('/v1/cluster/status');
return response.data.data;
},
getClusterStatistics: async (): Promise<ClusterStatistics> => {
const response = await api.get('/v1/cluster/statistics');
return response.data.data;
},
getNodeInfo: async (nodeId: string = 'self'): Promise<MultiNodeResponse> => {
const response = await api.get(`/v1/cluster/nodes/${nodeId}`);
return response.data.data;
},
getNodeStatistics: async (nodeId: string): Promise<MultiNodeStatisticsResponse> => {
const response = await api.get(`/v1/cluster/nodes/${nodeId}/statistics`);
return response.data.data;
},
getFullMetrics: async (): Promise<GarageMetrics> => {
// Fetch all cluster-related metrics
const [health, statistics, storageMetrics] = await Promise.all([
garageApi.getClusterHealth(),
garageApi.getClusterStatistics(),
analyticsApi.getMetrics(),
]);
return {
...storageMetrics,
clusterHealth: health,
clusterStatistics: statistics,
};
},
};
// Monitoring API
export const monitoringApi = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getMetrics: async (): Promise<any> => {
const response = await api.get('/v1/monitoring/metrics');
return response.data.data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
checkAdminHealth: async (): Promise<any> => {
const response = await api.get('/v1/monitoring/admin-health');
return response.data.data;
},
};
export default api;
-75
View File
@@ -1,75 +0,0 @@
// Grafana-inspired color palette for charts
export const grafanaColors = {
light: {
blue: '#1f77b4',
orange: '#ff7f0e',
green: '#2ca02c',
red: '#d62728',
purple: '#9467bd',
brown: '#8c564b',
pink: '#e377c2',
gray: '#7f7f7f',
olive: '#bcbd22',
cyan: '#17becf',
},
dark: {
blue: '#3eb0ff',
orange: '#ff9830',
green: '#73bf69',
red: '#f2495c',
purple: '#b581d8',
brown: '#c4a2e0',
pink: '#ff9d96',
gray: '#9ba3af',
olive: '#fac858',
cyan: '#37b7c3',
},
};
export const chartColorPalette = {
light: [
'#1f77b4', // Blue
'#ff7f0e', // Orange
'#2ca02c', // Green
'#d62728', // Red
'#9467bd', // Purple
'#8c564b', // Brown
'#e377c2', // Pink
'#7f7f7f', // Gray
'#bcbd22', // Olive
'#17becf', // Cyan
],
dark: [
'#3eb0ff', // Bright Blue
'#ff9830', // Orange
'#73bf69', // Green
'#f2495c', // Red
'#b581d8', // Purple
'#c4a2e0', // Pink
'#ff9d96', // Light Pink
'#9ba3af', // Gray
'#fac858', // Yellow
'#37b7c3', // Cyan
],
};
export const getChartColors = (isDark: boolean = false) => {
return isDark ? chartColorPalette.dark : chartColorPalette.light;
};
export const getTextColor = (isDark: boolean = false) => {
return isDark ? '#e0e0e0' : '#333333';
};
export const getGridColor = (isDark: boolean = false) => {
return isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
};
export const getTooltipStyle = (isDark: boolean = false) => {
return {
backgroundColor: isDark ? '#1f2937' : '#ffffff',
border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
borderRadius: '6px',
color: isDark ? '#e0e0e0' : '#333333',
};
};
-100
View File
@@ -1,100 +0,0 @@
/**
* Get the file type based on file extension
*/
export function getFileType(filename: string): string {
if (!filename) return 'Unknown';
const extension = filename.split('.').pop()?.toLowerCase() || '';
if (!extension) return 'File';
const typeMap: Record<string, string> = {
// Images
'png': 'Image',
'jpg': 'Image',
'jpeg': 'Image',
'gif': 'Image',
'svg': 'Image',
'webp': 'Image',
// Documents
'pdf': 'PDF',
'doc': 'Document',
'docx': 'Document',
'xls': 'Spreadsheet',
'xlsx': 'Spreadsheet',
'ppt': 'Presentation',
'pptx': 'Presentation',
'txt': 'Text',
// Archives
'zip': 'Archive',
'rar': 'Archive',
'gz': 'Archive',
'tar': 'Archive',
// Video/Audio
'mp4': 'Video',
'avi': 'Video',
'mov': 'Video',
'mkv': 'Video',
'webm': 'Video',
'mp3': 'Audio',
'wav': 'Audio',
'flac': 'Audio',
// Code
'js': 'JavaScript',
'ts': 'TypeScript',
'tsx': 'TypeScript',
'jsx': 'JavaScript',
'py': 'Python',
'java': 'Java',
'cpp': 'C++',
'c': 'C',
'html': 'HTML',
'css': 'CSS',
'json': 'JSON',
'xml': 'XML',
'sql': 'SQL',
// Data
'csv': 'CSV',
};
return typeMap[extension] || extension.toUpperCase();
}
/**
* Generate breadcrumbs from a file path
*/
export function getBreadcrumbs(currentPath: string): Array<{ label: string; path: string }> {
if (!currentPath) return [{ label: 'Root', path: '' }];
const parts = currentPath.split('/').filter(Boolean);
const breadcrumbs = [{ label: 'Root', path: '' }];
parts.forEach((part, index) => {
const path = parts.slice(0, index + 1).join('/') + '/';
breadcrumbs.push({ label: part, path });
});
return breadcrumbs;
}
/**
* Format relative time from a date
*/
export function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) !== 1 ? 's' : ''} ago`;
return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) !== 1 ? 's' : ''} ago`;
}
-43
View File
@@ -1,43 +0,0 @@
import {QueryClient} from '@tanstack/react-query';
// Create a query client with default options
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // Data is fresh for 5 minutes
gcTime: 10 * 60 * 1000, // Cache data for 10 minutes (formerly cacheTime)
retry: 1, // Retry failed requests once
refetchOnWindowFocus: false, // Don't refetch when window regains focus
refetchOnMount: false, // Don't refetch on component mount if data exists
placeholderData: (previousData: unknown) => previousData, // Keep previous data while fetching new data
},
},
});
// Query keys for consistent cache management
export const queryKeys = {
buckets: {
all: ['buckets'] as const,
list: () => [...queryKeys.buckets.all, 'list'] as const,
detail: (name: string) => [...queryKeys.buckets.all, 'detail', name] as const,
},
objects: {
all: ['objects'] as const,
list: (bucket: string, prefix?: string) => [...queryKeys.objects.all, 'list', bucket, prefix] as const,
},
accessKeys: {
all: ['accessKeys'] as const,
list: () => [...queryKeys.accessKeys.all, 'list'] as const,
detail: (keyId: string) => [...queryKeys.accessKeys.all, 'detail', keyId] as const,
},
cluster: {
all: ['cluster'] as const,
health: () => [...queryKeys.cluster.all, 'health'] as const,
status: () => [...queryKeys.cluster.all, 'status'] as const,
statistics: () => [...queryKeys.cluster.all, 'statistics'] as const,
},
dashboard: {
all: ['dashboard'] as const,
metrics: () => [...queryKeys.dashboard.all, 'metrics'] as const,
},
};
-34
View File
@@ -1,34 +0,0 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(d);
}
export function truncateString(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return `${str.slice(0, maxLength)}...`;
}
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
File diff suppressed because it is too large Load Diff
-220
View File
@@ -1,220 +0,0 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Header } from '@/components/layout/header';
import { useBuckets } from '@/hooks/useBuckets';
import { useBucketObjects } from '@/hooks/useBucketObjects';
import { BucketListView } from '@/components/buckets/BucketListView';
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
import { DeleteBucketDialog } from '@/components/buckets/DeleteBucketDialog';
import { BucketSettingsDialog } from '@/components/buckets/BucketSettingsDialog';
import type { Bucket } from '@/types';
import { toast } from 'sonner';
export function Buckets() {
const [searchParams, setSearchParams] = useSearchParams();
// Bucket state
const [searchQuery, setSearchQuery] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteBucketDialogOpen, setDeleteBucketDialogOpen] = useState(false);
const [selectedBucket, setSelectedBucket] = useState<Bucket | null>(null);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [settingsBucket, setSettingsBucket] = useState<Bucket | null>(null);
// Object browser state - initialize from URL params
const [viewingBucket, setViewingBucket] = useState<string | null>(searchParams.get('bucket'));
const [currentPath, setCurrentPath] = useState<string>(searchParams.get('prefix') || '');
const [objectSearchQuery, setObjectSearchQuery] = useState('');
const [initialPageToken, setInitialPageToken] = useState<string | undefined>(
searchParams.get('page') || undefined
);
const [initialItemsPerPage, setInitialItemsPerPage] = useState<number>(
parseInt(searchParams.get('limit') || '25', 10)
);
// Sync URL params with state on mount and when URL changes
useEffect(() => {
const bucketParam = searchParams.get('bucket');
const prefixParam = searchParams.get('prefix') || '';
const pageParam = searchParams.get('page') || undefined;
const limitParam = parseInt(searchParams.get('limit') || '25', 10);
if (bucketParam !== viewingBucket) {
setViewingBucket(bucketParam);
}
if (prefixParam !== currentPath) {
setCurrentPath(prefixParam);
}
setInitialPageToken(pageParam);
setInitialItemsPerPage(limitParam);
}, [searchParams]);
// Custom hooks
const { buckets, isLoading: bucketsLoading, createBucket, deleteBucket, grantPermission } = useBuckets();
const {
objects,
isLoading: objectsLoading,
isRefreshing,
isNavigating,
isTruncated,
nextContinuationToken,
itemsPerPage,
setItemsPerPage,
uploadFiles,
deleteObject,
deleteMultipleObjects,
createDirectory,
fetchObjects
} = useBucketObjects(
viewingBucket,
currentPath
);
const handleViewBucket = (bucketName: string) => {
setViewingBucket(bucketName);
setCurrentPath('');
setObjectSearchQuery('');
setSearchParams({ bucket: bucketName });
};
const handleBackToBuckets = () => {
setViewingBucket(null);
setCurrentPath('');
setObjectSearchQuery('');
setSearchParams({});
};
const handleNavigateToFolder = (path: string) => {
setCurrentPath(path);
if (viewingBucket) {
const params: Record<string, string> = { bucket: viewingBucket };
if (path) {
params.prefix = path;
}
// Reset pagination when navigating to a new folder
setSearchParams(params);
}
};
const handlePageChange = (token?: string) => {
fetchObjects(token);
// Update URL with page token
if (viewingBucket) {
const params: Record<string, string> = { bucket: viewingBucket };
if (currentPath) {
params.prefix = currentPath;
}
if (token) {
params.page = token;
}
if (itemsPerPage !== 25) {
params.limit = itemsPerPage.toString();
}
setSearchParams(params);
}
};
const handleItemsPerPageChange = (count: number) => {
setItemsPerPage(count);
// Update URL with new limit
if (viewingBucket) {
const params: Record<string, string> = { bucket: viewingBucket };
if (currentPath) {
params.prefix = currentPath;
}
if (count !== 25) {
params.limit = count.toString();
}
setSearchParams(params);
}
};
const handleOpenSettings = (bucket: Bucket) => {
setSettingsBucket(bucket);
setSettingsDialogOpen(true);
};
const handleRefreshObjects = async () => {
if (isRefreshing) return;
try {
await fetchObjects(undefined, true);
toast.success('Objects refreshed successfully');
} catch (error) {
console.error('Refresh error:', error);
}
};
// If viewing a bucket's objects, show the object browser view
if (viewingBucket) {
return (
<ObjectBrowserView
bucketName={viewingBucket}
objects={objects}
currentPath={currentPath}
searchQuery={objectSearchQuery}
isLoading={objectsLoading}
isTruncated={isTruncated}
nextContinuationToken={nextContinuationToken}
itemsPerPage={itemsPerPage}
onSearchChange={setObjectSearchQuery}
onNavigateToFolder={handleNavigateToFolder}
onBackToBuckets={handleBackToBuckets}
onUploadFiles={uploadFiles}
onDeleteObject={deleteObject}
onDeleteMultipleObjects={deleteMultipleObjects}
onCreateDirectory={createDirectory}
onRefresh={handleRefreshObjects}
onPageChange={handlePageChange}
onItemsPerPageChange={handleItemsPerPageChange}
isRefreshing={isRefreshing}
isNavigating={isNavigating}
initialPageToken={initialPageToken}
initialItemsPerPage={initialItemsPerPage}
/>
);
}
// Default view: show buckets list
return (
<div>
<Header title="Buckets" />
<div className="p-4 sm:p-6">
<BucketListView
buckets={buckets}
searchQuery={searchQuery}
isLoading={bucketsLoading}
onSearchChange={setSearchQuery}
onViewBucket={handleViewBucket}
onOpenSettings={handleOpenSettings}
onCreateBucket={() => setCreateDialogOpen(true)}
onDeleteBucket={(bucket) => {
setSelectedBucket(bucket);
setDeleteBucketDialogOpen(true);
}}
/>
</div>
{/* Dialogs */}
<CreateBucketDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onCreateBucket={createBucket}
/>
<DeleteBucketDialog
open={deleteBucketDialogOpen}
onOpenChange={setDeleteBucketDialogOpen}
bucket={selectedBucket}
onDeleteBucket={deleteBucket}
/>
<BucketSettingsDialog
open={settingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
bucket={settingsBucket}
onGrantPermission={grantPermission}
/>
</div>
);
}
-489
View File
@@ -1,489 +0,0 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Header} from '@/components/layout/header';
import {formatBytes} from '@/lib/utils';
import {Activity, AlertCircle, CheckCircle2, Clock, Cpu, Database, Info, Network, Server, XCircle,} from 'lucide-react';
import {useQuery} from '@tanstack/react-query';
import {garageApi} from '@/lib/api';
import {Badge} from '@/components/ui/badge';
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
import type {ClusterNode, LocalNodeInfo, NodeStatistics} from '@/types';
import {useState} from 'react';
export function Cluster() {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const { data: health, isLoading: healthLoading } = useQuery({
queryKey: ['cluster-health'],
queryFn: () => garageApi.getClusterHealth(),
refetchInterval: 10000,
});
const { data: status, isLoading: statusLoading } = useQuery({
queryKey: ['cluster-status'],
queryFn: () => garageApi.getClusterStatus(),
refetchInterval: 15000,
});
const { data: statistics, isLoading: statisticsLoading } = useQuery({
queryKey: ['cluster-statistics'],
queryFn: () => garageApi.getClusterStatistics(),
refetchInterval: 30000,
});
const { data: nodeInfo, isLoading: nodeInfoLoading } = useQuery({
queryKey: ['node-info', selectedNodeId || '*'],
queryFn: () => garageApi.getNodeInfo(selectedNodeId || '*'),
enabled: !!selectedNodeId || selectedNodeId === null,
});
const { data: nodeStats } = useQuery({
queryKey: ['node-statistics', selectedNodeId || '*'],
queryFn: () => garageApi.getNodeStatistics(selectedNodeId || '*'),
enabled: !!selectedNodeId,
});
const isLoading = healthLoading || statusLoading || statisticsLoading;
const getHealthStatus = () => {
if (!health) return { color: 'text-gray-500', bgColor: 'bg-gray-100', label: 'Unknown', icon: AlertCircle };
if (
health.storageNodesUp === health.storageNodes &&
health.partitionsAllOk === health.partitions &&
health.connectedNodes === health.knownNodes
) {
return { color: 'text-green-600', bgColor: 'bg-green-100', label: 'Healthy', icon: CheckCircle2 };
}
if (health.storageNodesUp > 0 && health.partitionsQuorum > 0) {
return { color: 'text-yellow-600', bgColor: 'bg-yellow-100', label: 'Degraded', icon: AlertCircle };
}
return { color: 'text-red-600', bgColor: 'bg-red-100', label: 'Unhealthy', icon: XCircle };
};
const healthStatus = getHealthStatus();
const HealthIcon = healthStatus.icon;
const getNodeStatus = (node: ClusterNode) => {
if (!node.isUp) {
return { color: 'text-red-600', bgColor: 'bg-red-100', label: 'Down', icon: XCircle };
}
if (node.draining) {
return { color: 'text-yellow-600', bgColor: 'bg-yellow-100', label: 'Draining', icon: AlertCircle };
}
return { color: 'text-green-600', bgColor: 'bg-green-100', label: 'Up', icon: CheckCircle2 };
};
const formatUptime = (seconds?: number) => {
if (!seconds) return 'N/A';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${days}d ${hours}h ${minutes}m`;
};
if (isLoading) {
return (
<div>
<Header title="Cluster" />
<div className="p-4 sm:p-6 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
<p className="mt-2 text-sm text-muted-foreground">Loading cluster information...</p>
</div>
</div>
</div>
);
}
return (
<div>
<Header title="Cluster Management" />
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* Cluster Health Overview */}
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Cluster Status</CardTitle>
<HealthIcon className={`h-4 w-4 ${healthStatus.color}`} />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${healthStatus.color}`}>{healthStatus.label}</div>
<p className="text-xs text-muted-foreground mt-2">
Layout v{status?.layoutVersion || 0}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Connected Nodes</CardTitle>
<Network className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{health?.connectedNodes || 0}/{health?.knownNodes || 0}
</div>
<p className="text-xs text-muted-foreground">
Nodes online
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Storage Nodes</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{health?.storageNodesUp || 0}/{health?.storageNodes || 0}
</div>
<p className="text-xs text-muted-foreground">
Healthy storage nodes
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Partitions</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{health?.partitionsAllOk || 0}/{health?.partitions || 0}
</div>
<p className="text-xs text-muted-foreground">
Healthy partitions
</p>
</CardContent>
</Card>
</div>
{/* Tabs for different views */}
<Tabs defaultValue="nodes" className="space-y-4">
<TabsList>
<TabsTrigger value="nodes">Nodes</TabsTrigger>
<TabsTrigger value="statistics">Statistics</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
{/* Nodes Tab */}
<TabsContent value="nodes" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Cluster Nodes</CardTitle>
<CardDescription>
Overview of all nodes in the Garage cluster
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{status?.nodes && status.nodes.length > 0 ? (
status.nodes.map((node) => {
const nodeStatus = getNodeStatus(node);
const NodeIcon = nodeStatus.icon;
const dataUsage = node.dataPartition
? ((node.dataPartition.total - node.dataPartition.available) / node.dataPartition.total) * 100
: 0;
const metadataUsage = node.metadataPartition
? ((node.metadataPartition.total - node.metadataPartition.available) / node.metadataPartition.total) * 100
: 0;
return (
<Card
key={node.id}
className={`cursor-pointer transition-all hover:shadow-md ${
selectedNodeId === node.id ? 'ring-2 ring-primary' : ''
}`}
onClick={() => setSelectedNodeId(node.id)}
>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-3">
<NodeIcon className={`h-5 w-5 ${nodeStatus.color}`} />
<div>
<div className="font-mono text-sm font-medium">
{node.id.substring(0, 16)}...
</div>
{node.hostname && (
<div className="text-xs text-muted-foreground">{node.hostname}</div>
)}
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-2">
<div>
<div className="text-xs text-muted-foreground">Status</div>
<Badge variant={node.isUp ? 'default' : 'destructive'} className="mt-1">
{nodeStatus.label}
</Badge>
</div>
{node.addr && (
<div>
<div className="text-xs text-muted-foreground">Address</div>
<div className="text-sm font-mono">{node.addr}</div>
</div>
)}
{node.garageVersion && (
<div>
<div className="text-xs text-muted-foreground">Version</div>
<div className="text-sm">{node.garageVersion}</div>
</div>
)}
{node.role && (
<div>
<div className="text-xs text-muted-foreground">Zone</div>
<div className="text-sm">{node.role.zone}</div>
</div>
)}
</div>
{node.role?.capacity && (
<div className="pt-2">
<div className="text-xs text-muted-foreground mb-1">
Capacity: {formatBytes(node.role.capacity)}
</div>
</div>
)}
{(node.dataPartition || node.metadataPartition) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
{node.dataPartition && (
<div>
<div className="text-xs text-muted-foreground mb-1">
Data Partition: {formatBytes(node.dataPartition.total - node.dataPartition.available)} / {formatBytes(node.dataPartition.total)}
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
dataUsage > 90 ? 'bg-red-500' : dataUsage > 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${dataUsage}%` }}
/>
</div>
</div>
)}
{node.metadataPartition && (
<div>
<div className="text-xs text-muted-foreground mb-1">
Metadata Partition: {formatBytes(node.metadataPartition.total - node.metadataPartition.available)} / {formatBytes(node.metadataPartition.total)}
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
metadataUsage > 90 ? 'bg-red-500' : metadataUsage > 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${metadataUsage}%` }}
/>
</div>
</div>
)}
</div>
)}
{!node.isUp && node.lastSeenSecsAgo !== undefined && (
<div className="text-xs text-muted-foreground pt-2">
<Clock className="inline h-3 w-3 mr-1" />
Last seen: {node.lastSeenSecsAgo === null ? 'Never' : formatUptime(node.lastSeenSecsAgo) + ' ago'}
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
})
) : (
<div className="text-center text-muted-foreground py-8">
<Server className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No nodes found in the cluster</p>
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Statistics Tab */}
<TabsContent value="statistics" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Cluster Statistics</CardTitle>
<CardDescription>
Detailed statistics and metrics from the Garage cluster
</CardDescription>
</CardHeader>
<CardContent>
{statistics ? (
<div className="space-y-4">
<div className="rounded-lg bg-muted p-4">
<pre className="text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{statistics.freeform}
</pre>
</div>
</div>
) : (
<div className="text-center text-muted-foreground py-8">
<Activity className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No statistics available</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Details Tab */}
<TabsContent value="details" className="space-y-4">
{selectedNodeId ? (
<>
<Card>
<CardHeader>
<CardTitle>Node Information</CardTitle>
<CardDescription>
Detailed information for node: {selectedNodeId.substring(0, 16)}...
</CardDescription>
</CardHeader>
<CardContent>
{nodeInfoLoading ? (
<div className="text-center py-8">
<div className="inline-block h-6 w-6 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
<p className="mt-2 text-sm text-muted-foreground">Loading node info...</p>
</div>
) : nodeInfo ? (
<div className="space-y-4">
{/* Success responses */}
{Object.entries(nodeInfo.success || {}).map(([nodeId, info]) => (
<div key={nodeId} className="space-y-3">
<div className="flex items-center gap-2 mb-3">
<Info className="h-4 w-4 text-primary" />
<h4 className="font-medium">
Node: {nodeId.substring(0, 16)}...
</h4>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-1">Node ID</div>
<div className="font-mono text-sm break-all">{(info as LocalNodeInfo).nodeId}</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-1">Garage Version</div>
<div className="text-sm">{(info as LocalNodeInfo).garageVersion}</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-1">Rust Version</div>
<div className="text-sm">{(info as LocalNodeInfo).rustVersion}</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-1">Database Engine</div>
<div className="text-sm">{(info as LocalNodeInfo).dbEngine}</div>
</div>
</div>
{(info as LocalNodeInfo).garageFeatures && (info as LocalNodeInfo).garageFeatures!.length > 0 && (
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground mb-2">Garage Features</div>
<div className="flex flex-wrap gap-2">
{(info as LocalNodeInfo).garageFeatures!.map((feature) => (
<Badge key={feature} variant="secondary">
{feature}
</Badge>
))}
</div>
</div>
)}
</div>
))}
{/* Error responses */}
{Object.entries(nodeInfo.error || {}).map(([nodeId, error]) => (
<div key={nodeId} className="rounded-lg border border-red-200 bg-red-50 p-3">
<div className="flex items-center gap-2 text-red-600 mb-1">
<XCircle className="h-4 w-4" />
<div className="font-medium">Error for node {nodeId.substring(0, 16)}...</div>
</div>
<div className="text-sm text-red-800">{error}</div>
</div>
))}
</div>
) : (
<div className="text-center text-muted-foreground py-8">
<Info className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No node information available</p>
</div>
)}
</CardContent>
</Card>
{nodeStats && (
<Card>
<CardHeader>
<CardTitle>Node Statistics</CardTitle>
<CardDescription>
Performance metrics for the selected node
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Success responses */}
{Object.entries(nodeStats.success || {}).map(([nodeId, stats]) => (
<div key={nodeId} className="space-y-3">
<div className="flex items-center gap-2 mb-3">
<Cpu className="h-4 w-4 text-primary" />
<h4 className="font-medium">
Statistics for: {nodeId.substring(0, 16)}...
</h4>
</div>
<div className="rounded-lg bg-muted p-4">
<pre className="text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{(stats as NodeStatistics).freeform}
</pre>
</div>
</div>
))}
{/* Error responses */}
{Object.entries(nodeStats.error || {}).map(([nodeId, error]) => (
<div key={nodeId} className="rounded-lg border border-red-200 bg-red-50 p-3">
<div className="flex items-center gap-2 text-red-600 mb-1">
<XCircle className="h-4 w-4" />
<div className="font-medium">Error for node {nodeId.substring(0, 16)}...</div>
</div>
<div className="text-sm text-red-800">{error}</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</>
) : (
<Card>
<CardContent className="pt-6">
<div className="text-center text-muted-foreground py-12">
<Server className="h-16 w-16 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">Select a Node</p>
<p className="text-sm">
Click on a node in the Nodes tab to view detailed information and statistics
</p>
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
</div>
);
}
-244
View File
@@ -1,244 +0,0 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Header} from '@/components/layout/header';
import {formatBytes} from '@/lib/utils';
import {AlertCircle, Database, FolderOpen, HardDrive, Server, Zap} from 'lucide-react';
import {BucketUsageChart} from '@/components/charts/BucketUsageChart';
import {useDashboardData} from '@/hooks/useApi';
import type {ClusterHealth} from '@/types';
export function Dashboard() {
const { metrics: metricsQuery, buckets: bucketsQuery, health: healthQuery, isLoading } = useDashboardData();
const metrics = metricsQuery.data;
const buckets = bucketsQuery.data || [];
const clusterHealth = healthQuery.data;
const getHealthStatus = (health: ClusterHealth | null) => {
if (!health) return { color: 'text-gray-500', label: 'Unknown', icon: AlertCircle };
if (
health.storageNodesUp === health.storageNodes &&
health.partitionsAllOk === health.partitions &&
health.connectedNodes === health.knownNodes
) {
return { color: 'text-green-500', label: 'Healthy', icon: Zap };
}
if (
health.storageNodesUp > 0 &&
health.partitionsQuorum > 0
) {
return { color: 'text-yellow-500', label: 'Degraded', icon: AlertCircle };
}
return { color: 'text-red-500', label: 'Unhealthy', icon: AlertCircle };
};
const healthStatus = getHealthStatus(clusterHealth ?? null);
if (isLoading) {
return (
<div>
<Header title="Dashboard" />
<div className="p-4 sm:p-6 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
<p className="mt-2 text-sm text-muted-foreground">Loading dashboard...</p>
</div>
</div>
</div>
);
}
return (
<div>
<Header title="Dashboard" />
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* Top Stats Grid */}
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Storage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metrics ? formatBytes(metrics.totalSize) : '---'}
</div>
<p className="text-xs text-muted-foreground">
Across {metrics?.bucketCount || 0} buckets
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Objects</CardTitle>
<FolderOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metrics?.objectCount.toLocaleString() || '---'}
</div>
<p className="text-xs text-muted-foreground">
Files and folders
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Buckets</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metrics?.bucketCount || '---'}</div>
<p className="text-xs text-muted-foreground">
Active storage buckets
</p>
</CardContent>
</Card>
</div>
{/* Cluster Status */}
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Cluster Status</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className={`text-2xl font-bold ${healthStatus.color}`}>
{healthStatus.label}
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
{clusterHealth?.connectedNodes || 0}/{clusterHealth?.knownNodes || 0} nodes connected
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Storage Nodes</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{clusterHealth?.storageNodesUp || 0}/{clusterHealth?.storageNodes || 0}
</div>
<p className="text-xs text-muted-foreground">
Healthy storage nodes
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Partitions</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{clusterHealth?.partitionsAllOk || 0}/{clusterHealth?.partitions || 0}
</div>
<p className="text-xs text-muted-foreground">
Healthy partitions
</p>
</CardContent>
</Card>
</div>
{/* Charts Row 1 */}
<div className="grid gap-3 sm:gap-4 grid-cols-1 lg:grid-cols-2">
{/* Bucket Usage Chart */}
<Card>
<CardHeader>
<CardTitle>Storage Usage by Bucket</CardTitle>
<CardDescription>Distribution of storage across buckets</CardDescription>
</CardHeader>
<CardContent>
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
<BucketUsageChart data={metrics.usageByBucket} />
) : (
<div className="text-center py-8 text-muted-foreground">No data available</div>
)}
</CardContent>
</Card>
{/* Bucket Details Table */}
<Card>
<CardHeader>
<CardTitle>Storage Usage by Bucket (Table)</CardTitle>
<CardDescription>Detailed breakdown of storage across all buckets</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
metrics.usageByBucket.map((bucket) => (
<div key={bucket.bucketName} className="space-y-2">
<div className="flex items-center justify-between text-sm flex-wrap gap-2">
<span className="font-medium">{bucket.bucketName}</span>
<div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm">
<span className="text-muted-foreground">
{bucket.objectCount.toLocaleString()} objects
</span>
<span className="font-medium">{formatBytes(bucket.size)}</span>
<span className="text-muted-foreground w-12 text-right">
{bucket.percentage.toFixed(1)}%
</span>
</div>
</div>
<div className="h-2 rounded-full bg-secondary overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${bucket.percentage}%` }}
/>
</div>
</div>
))
) : (
<div className="text-center py-8 text-muted-foreground">No buckets available</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* Recent Buckets */}
<Card>
<CardHeader>
<CardTitle>Recent Buckets</CardTitle>
<CardDescription>Your most recently created buckets</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{buckets.slice(0, 5).map((bucket) => (
<div
key={bucket.name}
className="flex items-center justify-between py-3 border-b last:border-0 gap-3"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<Database className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0">
<p className="font-medium truncate">{bucket.name}</p>
<p className="text-xs sm:text-sm text-muted-foreground">
Created {new Date(bucket.creationDate).toLocaleDateString()}
</p>
</div>
</div>
<div className="text-right flex-shrink-0">
<p className="font-medium text-sm sm:text-base">{bucket.objectCount?.toLocaleString()} objects</p>
<p className="text-xs sm:text-sm text-muted-foreground">
{bucket.size ? formatBytes(bucket.size) : '---'}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
-256
View File
@@ -1,256 +0,0 @@
// Bucket types
export interface Bucket {
name: string;
creationDate: string;
objectCount?: number;
size?: number;
region?: string;
}
export interface BucketDetails extends Bucket {
versioning?: boolean;
encryption?: boolean;
publicAccess?: boolean;
lifecycleRules?: LifecycleRule[];
}
export interface LifecycleRule {
id: string;
enabled: boolean;
prefix?: string;
expirationDays?: number;
transitions?: Transition[];
}
export interface Transition {
days: number;
storageClass: string;
}
// Object types
export interface S3Object {
key: string;
size: number;
lastModified: string;
etag?: string;
storageClass?: string;
isFolder?: boolean;
}
export interface ObjectListResponse {
bucket: string;
objects: S3Object[];
prefixes: string[];
count: number;
isTruncated: boolean;
nextContinuationToken?: string;
}
export interface ObjectMetadata {
key: string;
size: number;
lastModified: string;
contentType: string;
etag: string;
metadata?: Record<string, string>;
versionId?: string;
}
// Access Control types
export interface AccessKey {
accessKeyId: string;
name: string;
secretKey?: string;
createdAt: string;
lastUsed?: string;
status: 'active' | 'inactive';
permissions: BucketPermission[];
expiration?: string;
}
export interface BucketPermission {
bucketId: string;
bucketName: string;
read: boolean;
write: boolean;
owner: boolean;
}
export interface Permission {
resource: string;
actions: string[];
effect: 'Allow' | 'Deny';
}
export interface BucketPolicy {
bucketName: string;
policy: PolicyStatement[];
}
export interface PolicyStatement {
sid?: string;
effect: 'Allow' | 'Deny';
principal: string | string[];
action: string | string[];
resource: string | string[];
condition?: Record<string, any>;
}
// User types
export interface User {
id: string;
username: string;
email: string;
role: 'admin' | 'user' | 'readonly';
createdAt: string;
}
// Storage Analytics types
export interface StorageMetrics {
totalSize: number;
objectCount: number;
bucketCount: number;
usageByBucket: BucketUsage[];
requestMetrics: RequestMetrics;
}
export interface BucketUsage {
bucketName: string;
size: number;
objectCount: number;
percentage: number;
}
export interface RequestMetrics {
getRequests: number;
putRequests: number;
deleteRequests: number;
listRequests: number;
period: string;
}
// Upload types
export interface UploadTask {
id: string;
file: File;
key: string;
bucket: string;
progress: number;
status: 'pending' | 'uploading' | 'completed' | 'error';
error?: string;
}
// API Response types
export interface ApiResponse<T> {
data?: T;
error?: string;
message?: string;
}
// Filter and Sort types
export interface TableFilter {
search?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
pageSize?: number;
page?: number;
}
// Garage Cluster types
export interface ClusterHealth {
status: string;
connectedNodes: number;
knownNodes: number;
storageNodes: number;
storageNodesUp: number;
partitions: number;
partitionsQuorum: number;
partitionsAllOk: number;
}
export interface ClusterStatistics {
timestamp: number;
uptime: number;
freeform: string;
[key: string]: any;
}
export interface ClusterStatus {
layoutVersion: number;
nodes: ClusterNode[];
}
export interface ClusterNode {
id: string;
isUp: boolean;
lastSeenSecsAgo?: number;
hostname?: string;
addr?: string;
garageVersion?: string;
role?: NodeRole;
draining: boolean;
dataPartition?: FreeSpaceInfo;
metadataPartition?: FreeSpaceInfo;
}
export interface NodeRole {
zone: string;
capacity?: number;
tags: string[];
}
export interface FreeSpaceInfo {
available: number;
total: number;
}
export interface LocalNodeInfo {
nodeId: string;
garageVersion: string;
rustVersion: string;
dbEngine: string;
garageFeatures?: string[];
}
export interface MultiNodeResponse {
success: Record<string, LocalNodeInfo>;
error: Record<string, string>;
}
export interface NodeStatistics {
freeform: string;
}
export interface MultiNodeStatisticsResponse {
success: Record<string, NodeStatistics>;
error: Record<string, string>;
}
export interface NodeInfo {
nodeId: string;
version: string;
rustVersion: string;
uptime: number;
dbSize: number;
blockReferenceTableSize: number;
blockMetricsTableSize: number;
objectTableSize: number;
objectVersionTableSize: number;
bucketTableSize: number;
bucketAliasTableSize: number;
[key: string]: any;
}
export interface RequestTypeMetrics {
read: number;
write: number;
delete: number;
list: number;
}
export interface GarageMetrics extends StorageMetrics {
clusterHealth?: ClusterHealth;
clusterStatistics?: ClusterStatistics;
nodeInfo?: NodeInfo;
requestTypeMetrics?: RequestTypeMetrics;
}
-53
View File
@@ -1,53 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
border: 'var(--border)',
input: 'var(--input)',
ring: 'var(--ring)',
background: 'var(--background)',
foreground: 'var(--foreground)',
primary: {
DEFAULT: 'var(--primary)',
foreground: 'var(--primary-foreground)',
},
secondary: {
DEFAULT: 'var(--secondary)',
foreground: 'var(--secondary-foreground)',
},
destructive: {
DEFAULT: 'var(--destructive)',
foreground: 'var(--destructive-foreground)',
},
muted: {
DEFAULT: 'var(--muted)',
foreground: 'var(--muted-foreground)',
},
accent: {
DEFAULT: 'var(--accent)',
foreground: 'var(--accent-foreground)',
},
popover: {
DEFAULT: 'var(--popover)',
foreground: 'var(--popover-foreground)',
},
card: {
DEFAULT: 'var(--card)',
foreground: 'var(--card-foreground)',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
}
-34
View File
@@ -1,34 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
-7
View File
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
-28
View File
@@ -1,28 +0,0 @@
import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: '/', // Base path for production (served from root)
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
assetsDir: 'assets',
sourcemap: false,
},
})

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

-23
View File
@@ -1,23 +0,0 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
-19
View File
@@ -1,19 +0,0 @@
apiVersion: v2
name: garage-ui
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
type: application
version: 0.1.2
appVersion: "v0.0.6"
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
home: https://github.com/Noooste/garage-ui
sources:
- https://github.com/Noooste/garage-ui
maintainers:
- name: Noooste
kubeVersion: ">=1.19.0-0"
-858
View File
@@ -1,858 +0,0 @@
# Garage UI Helm Chart
A Helm chart for deploying [Garage UI](https://github.com/Noooste/garage-ui), a modern web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems.
[![Version](https://img.shields.io/badge/version-0.1.1-blue.svg)](Chart.yaml)
[![App Version](https://img.shields.io/badge/app%20version-v0.0.6-green.svg)](Chart.yaml)
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Installation](#installation)
- [Configuration](#configuration)
- [Examples](#examples)
- [Upgrading](#upgrading)
- [Accessing the Application](#accessing-the-application)
- [Monitoring](#monitoring)
- [Troubleshooting](#troubleshooting)
- [Uninstalling](#uninstalling)
## Overview
Garage UI provides an intuitive web interface for managing your Garage S3 storage cluster, featuring:
- **Bucket Management** - Create, delete, and configure S3 buckets
- **Object Operations** - Upload, download, and delete objects through the UI
- **User Access Control** - Manage users, access keys, and permissions
- **Cluster Monitoring** - View cluster health, status, and statistics
- **Authentication** - Support for no auth, basic auth, and OIDC/SSO
## Prerequisites
Before installing this chart, ensure you have:
- **Kubernetes** `1.19+` or later
- **Helm** `3.0+` or later
- **Garage S3** instance running and accessible
- **Garage Admin Token** - Required for administrative operations
### Required Information
You will need the following information from your Garage installation:
1. **Garage S3 Endpoint** - The Garage S3 API endpoint (default port: `3900`)
2. **Garage Admin Endpoint** - The Garage Admin API endpoint (default port: `3903`)
3. **Admin Token** - Bearer token for authenticating with the Admin API
To find your admin token, check your Garage server's configuration file.
## Quick Start
The fastest way to get started with Garage UI:
### Step 1: Create a values file
Create a file named `my-values.yaml` with your Garage configuration:
```yaml
config:
garage:
endpoint: "http://garage:3900" # Your Garage S3 endpoint
admin_endpoint: "http://garage:3903" # Your Garage Admin endpoint
admin_token: "YOUR_ADMIN_TOKEN_HERE" # Your admin token
region: "garage" # S3 region (can be any value)
```
### Step 2: Install the chart
```bash
helm install garage-ui ./helm/garage-ui -f my-values.yaml
```
### Step 3: Access the UI
```bash
# Forward port to access locally
kubectl port-forward svc/garage-ui 8080:80
# Open in your browser
open http://localhost:8080
```
That's it! You should now have Garage UI running and accessible.
## Installation
### Installing from local chart
If you've cloned the repository:
```bash
helm install garage-ui ./helm/garage-ui -f my-values.yaml
```
### Installing with inline values
You can also set values directly on the command line:
```bash
helm install garage-ui ./helm/garage-ui \
--set config.garage.endpoint=http://garage:3900 \
--set config.garage.admin_endpoint=http://garage:3903 \
--set config.garage.admin_token=your-token-here
```
### Verify installation
Check that the pod is running:
```bash
kubectl get pods -l app.kubernetes.io/name=garage-ui
```
View the logs:
```bash
kubectl logs -l app.kubernetes.io/name=garage-ui
```
## Configuration
### Configuration Structure
The chart uses a structured `config` section that maps directly to the application's configuration file. You can override any value using Helm's standard methods.
### Minimal Configuration
The absolute minimum configuration requires only the Garage endpoints and admin token:
```yaml
config:
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
```
### Common Configuration Options
#### Server Settings
```yaml
config:
server:
port: 8080 # Application port
environment: production # Environment (production/development/staging)
```
#### Authentication Modes
**No Authentication** (default, suitable for private networks):
```yaml
config:
auth:
mode: "none"
```
**Basic Authentication**:
```yaml
config:
auth:
mode: "basic"
basic:
username: "admin"
password: "your-secure-password"
```
**OIDC/SSO** (recommended for production):
```yaml
config:
auth:
mode: "oidc"
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-oidc-secret"
issuer_url: "https://auth.example.com/realms/master"
# ... additional OIDC settings
```
#### CORS Configuration
```yaml
config:
cors:
enabled: true
allowed_origins:
- "https://garage-ui.example.com" # Recommended: specific origins
# - "*" # Or: allow all origins (less secure)
```
#### Logging
```yaml
config:
logging:
level: info # debug, info, warn, error
format: json # json (recommended for production), text
```
### Kubernetes Resource Configuration
#### Ingress
Enable external access with Ingress:
```yaml
ingress:
enabled: true
className: nginx
hosts:
- host: garage-ui.example.com
paths:
- path: /
pathType: Prefix
```
With TLS:
```yaml
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: garage-ui.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-ui-tls
hosts:
- garage-ui.example.com
```
#### Resource Limits
```yaml
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
```
#### Replicas and High Availability
```yaml
replicaCount: 3
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- garage-ui
topologyKey: kubernetes.io/hostname
```
### Complete Parameters Reference
For a complete list of all available parameters, see the [values.yaml](values.yaml) file which includes detailed comments for every configuration option.
## Examples
### Example 1: Basic Installation (In-Cluster Garage)
For Garage running in the same Kubernetes cluster:
```yaml
# values-basic.yaml
config:
garage:
endpoint: "http://garage.garage-system.svc.cluster.local:3900"
admin_endpoint: "http://garage.garage-system.svc.cluster.local:3903"
admin_token: "GgJLd9J...your-token-here"
region: "garage"
```
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-basic.yaml
```
### Example 2: External Access with Ingress
```yaml
# values-ingress.yaml
config:
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
hosts:
- host: garage-ui.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-ui-tls
hosts:
- garage-ui.example.com
```
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-ingress.yaml
```
### Example 3: With Basic Authentication
```yaml
# values-basic-auth.yaml
config:
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
auth:
mode: "basic"
basic:
username: "admin"
password: "super-secret-password-change-me"
ingress:
enabled: true
className: nginx
hosts:
- host: garage.local
paths:
- path: /
pathType: Prefix
```
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-basic-auth.yaml
```
### Example 4: Production Setup with OIDC (Keycloak)
```yaml
# values-production.yaml
replicaCount: 3
config:
server:
environment: production
garage:
endpoint: "https://s3.garage.example.com"
admin_endpoint: "https://admin.garage.example.com"
admin_token: "your-admin-token"
region: "us-east-1"
auth:
mode: "oidc"
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-oidc-client-secret"
scopes:
- openid
- email
- profile
issuer_url: "https://auth.example.com/realms/production"
auth_url: "https://auth.example.com/realms/production/protocol/openid-connect/auth"
token_url: "https://auth.example.com/realms/production/protocol/openid-connect/token"
userinfo_url: "https://auth.example.com/realms/production/protocol/openid-connect/userinfo"
cookie_secure: true
cookie_http_only: true
cookie_same_site: "lax"
cors:
enabled: true
allowed_origins:
- "https://garage-ui.example.com"
logging:
level: info
format: json
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
hosts:
- host: garage-ui.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-ui-tls
hosts:
- garage-ui.example.com
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 200m
memory: 256Mi
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- garage-ui
topologyKey: kubernetes.io/hostname
```
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-production.yaml
```
### Example 5: With Prometheus Monitoring
```yaml
# values-monitoring.yaml
config:
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
serviceMonitor:
enabled: true
interval: 30s
labels:
prometheus: kube-prometheus
```
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-monitoring.yaml
```
### Example 6: Using Kubernetes Secrets for Admin Token (Recommended)
For improved security, store the admin token in a Kubernetes secret instead of in values files:
**Option A: Use an existing secret**
First, create a Kubernetes secret:
```bash
kubectl create secret generic garage-admin-token \
--from-literal=admin-token='your-admin-token-here'
```
Then configure the chart to use it:
```yaml
# values-with-secret.yaml
config:
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
# Leave admin_token empty when using existingSecret
admin_token: ""
# Reference the existing secret
existingSecret:
name: "garage-admin-token"
key: "admin-token"
```
Install:
```bash
helm install garage-ui ./helm/garage-ui -f values-with-secret.yaml
```
**Option B: Let the chart create the secret**
If you provide `admin_token` in values but don't configure `existingSecret`, the chart will automatically create a secret for you:
```yaml
# values-auto-secret.yaml
config:
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
# Chart will create a secret with this value
admin_token: "your-admin-token"
```
This approach keeps the token out of the ConfigMap while still allowing you to manage it through Helm values.
## Upgrading
### Upgrade to a new version
```bash
helm upgrade garage-ui ./helm/garage-ui -f my-values.yaml
```
### Upgrade with new values
```bash
helm upgrade garage-ui ./helm/garage-ui \
--set config.logging.level=debug \
--reuse-values
```
### Check upgrade history
```bash
helm history garage-ui
```
### Rollback to previous version
```bash
helm rollback garage-ui
```
## Accessing the Application
### Method 1: Port Forward (Development)
Quick access for testing:
```bash
kubectl port-forward svc/garage-ui 8080:80
```
Then open: http://localhost:8080
### Method 2: Ingress (Production)
If you've enabled Ingress with a domain:
```bash
# Access via your configured domain
open https://garage-ui.example.com
```
### Method 3: NodePort (Alternative)
Change service type to NodePort:
```yaml
service:
type: NodePort
port: 80
```
Find the assigned port:
```bash
kubectl get svc garage-ui
```
### Method 4: LoadBalancer (Cloud Environments)
For cloud providers with LoadBalancer support:
```yaml
service:
type: LoadBalancer
port: 80
```
Get the external IP:
```bash
kubectl get svc garage-ui
```
## Monitoring
### Prometheus ServiceMonitor
Enable Prometheus metrics scraping (requires Prometheus Operator):
```yaml
serviceMonitor:
enabled: true
interval: 30s
path: /api/v1/monitoring/metrics
labels:
prometheus: kube-prometheus
```
### Metrics Endpoint
The application exposes metrics at:
- Path: `/api/v1/monitoring/metrics`
- Format: Prometheus format (proxies Garage Admin API metrics)
### Health Checks
Health endpoint available at:
- Path: `/health`
- Response: `{"status": "ok", "version": "0.1.0"}`
Used by Kubernetes liveness and readiness probes.
## Troubleshooting
### Pods Not Starting
Check pod status and logs:
```bash
kubectl get pods -l app.kubernetes.io/name=garage-ui
kubectl describe pod -l app.kubernetes.io/name=garage-ui
kubectl logs -l app.kubernetes.io/name=garage-ui
```
Common issues:
- **Missing admin token**: Ensure `config.garage.admin_token` is set or `config.garage.existingSecret.name` points to a valid secret
- **Secret not found**: If using `existingSecret`, verify the secret exists: `kubectl get secret <secret-name>`
- **Unreachable Garage**: Verify endpoints are accessible from within the cluster
- **Invalid OIDC config**: Check all OIDC URLs and credentials when using `auth.mode: oidc`
### Cannot Access the UI
For Ingress issues:
```bash
kubectl get ingress
kubectl describe ingress garage-ui
```
Check Ingress controller logs:
```bash
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx
```
Common issues:
- **DNS not configured**: Ensure your domain points to the Ingress controller
- **Certificate issues**: Check cert-manager logs if using automatic TLS
- **Ingress class mismatch**: Verify `ingress.className` matches your controller
### Configuration Not Updating
The deployment includes a ConfigMap checksum annotation that automatically triggers pod restarts when configuration changes. If it's not working:
```bash
kubectl rollout restart deployment/garage-ui
```
### Connection to Garage Fails
Test connectivity from within the pod:
```bash
kubectl exec -it deployment/garage-ui -- sh
# Inside the pod:
curl http://garage:3900
curl http://garage:3903/health
```
### Authentication Issues
**Basic Auth:**
- Verify username/password in `config.auth.basic`
- Check browser developer tools for 401 errors
**OIDC:**
- Verify all OIDC URLs are accessible
- Check OIDC provider logs
- Ensure redirect URI is registered: `https://your-domain/auth/callback`
- Verify client ID and secret
### View Application Logs
```bash
# Follow logs
kubectl logs -f -l app.kubernetes.io/name=garage-ui
# View recent logs
kubectl logs --tail=100 -l app.kubernetes.io/name=garage-ui
# Export logs
kubectl logs -l app.kubernetes.io/name=garage-ui > garage-ui.log
```
### Debug Mode
Enable debug logging:
```yaml
config:
logging:
level: debug
```
## Uninstalling
### Remove the release
```bash
helm uninstall garage-ui
```
This removes all Kubernetes components associated with the chart.
### Clean up completely
If you want to remove all associated resources:
```bash
# Uninstall the release
helm uninstall garage-ui
# Remove any remaining ConfigMaps
kubectl delete configmap -l app.kubernetes.io/name=garage-ui
# Remove any remaining Secrets
kubectl delete secret -l app.kubernetes.io/name=garage-ui
```
## Advanced Configuration
### Using Secrets for Sensitive Data
The chart supports using Kubernetes secrets for sensitive data to avoid storing credentials in values files.
#### Admin Token from Secret
The chart has built-in support for storing the admin token in a Kubernetes secret:
**Method 1: Use an existing secret** (recommended)
```bash
# Create the secret
kubectl create secret generic garage-admin-token \
--from-literal=admin-token='your-admin-token-here'
```
Configure in values:
```yaml
config:
garage:
existingSecret:
name: "garage-admin-token"
key: "admin-token"
```
**Method 2: Let the chart create the secret**
Simply provide the `admin_token` in values, and the chart will automatically create a secret:
```yaml
config:
garage:
admin_token: "your-admin-token"
```
The chart will:
1. Create a secret named `<release-name>-admin-token` with the token
2. Remove the token from the ConfigMap
3. Inject it into the pod via environment variable
This keeps sensitive data out of ConfigMaps while maintaining easy Helm-based management.
#### OIDC Client Secret
For OIDC credentials, you can similarly use secrets (requires manual template modification):
```bash
kubectl create secret generic garage-oidc \
--from-literal=client-secret='your-oidc-secret'
```
### Network Policies
Enable network policies for enhanced security:
```yaml
networkPolicy:
enabled: true
policyTypes:
- Ingress
- Egress
```
This restricts network traffic to/from the pods. Requires a CNI that supports NetworkPolicy (e.g., Calico, Cilium).
### Custom Container Images
Use a custom or private registry:
```yaml
image:
repository: myregistry.example.com/garage-ui
tag: "v1.0.0"
pullPolicy: Always
imagePullSecrets:
- name: myregistrykey
```
## Version Compatibility
| Chart Version | App Version | Kubernetes | Garage |
|--------------|-------------|------------|--------|
| 0.1.1 | v0.0.6 | 1.19+ | 0.8+ |
## Additional Resources
- **GitHub Repository**: https://github.com/Noooste/garage-ui
- **Garage Documentation**: https://garagehq.deuxfleurs.fr/
- **Issue Tracker**: https://github.com/Noooste/garage-ui/issues
- **Helm Documentation**: https://helm.sh/docs/
## Support
For help and support:
1. **Documentation**: Check this README and the [values.yaml](values.yaml) file
2. **GitHub Issues**: Report bugs or request features at https://github.com/Noooste/garage-ui/issues
3. **Garage Community**: Visit https://garagehq.deuxfleurs.fr/ for Garage-specific questions
## Contributing
Contributions are welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
## License
This Helm chart is open source and distributed under the same license as Garage UI.
-27
View File
@@ -1,27 +0,0 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "garage-ui.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "garage-ui.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "garage-ui.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "garage-ui.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}
2. To view logs:
kubectl logs -f deployment/{{ include "garage-ui.fullname" . }} -n {{ .Release.Namespace }}
For more information, see the README at https://github.com/Noooste/garage-ui/blob/main/helm/garage-ui/README.md
-51
View File
@@ -1,51 +0,0 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "garage-ui.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "garage-ui.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "garage-ui.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "garage-ui.labels" -}}
helm.sh/chart: {{ include "garage-ui.chart" . }}
{{ include "garage-ui.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "garage-ui.selectorLabels" -}}
app.kubernetes.io/name: {{ include "garage-ui.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
-11
View File
@@ -1,11 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "garage-ui.fullname" . }}-config
labels:
{{- include "garage-ui.labels" . | nindent 4 }}
data:
config.yaml: |
{{- $config := deepCopy .Values.config }}
{{- $_ := unset $config.garage "admin_token" }}
{{- $config | toYaml | nindent 4 }}
-91
View File
@@ -1,91 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "garage-ui.fullname" . }}
labels:
{{- include "garage-ui.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "garage-ui.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "garage-ui.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
protocol: TCP
env:
- name: GARAGE_UI_GARAGE_ADMIN_TOKEN
valueFrom:
secretKeyRef:
{{- if .Values.config.garage.existingSecret.name }}
name: {{ .Values.config.garage.existingSecret.name }}
key: {{ .Values.config.garage.existingSecret.key }}
{{- else }}
name: {{ include "garage-ui.fullname" . }}-admin-token
key: admin-token
{{- end }}
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
httpGet:
path: {{ .Values.livenessProbe.httpGet.path }}
port: {{ .Values.livenessProbe.httpGet.port }}
initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }}
failureThreshold: {{ .Values.livenessProbe.failureThreshold }}
{{- end }}
{{- if .Values.readinessProbe.enabled }}
readinessProbe:
httpGet:
path: {{ .Values.readinessProbe.httpGet.path }}
port: {{ .Values.readinessProbe.httpGet.port }}
initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.readinessProbe.periodSeconds }}
timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }}
failureThreshold: {{ .Values.readinessProbe.failureThreshold }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: config
mountPath: /app/config.yaml
subPath: config.yaml
readOnly: true
volumes:
- name: config
configMap:
name: {{ include "garage-ui.fullname" . }}-config
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

Some files were not shown because too many files have changed in this diff Show More