mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 15:58:13 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80553c6450 | |||
| 7b31490488 | |||
| 7419d5a14b | |||
| 4864185183 | |||
| 7aa5aeb4f8 | |||
| b4bdd13743 | |||
| 62b76769d0 | |||
| 31b3aca8f9 | |||
| 61dae6c605 | |||
| b49c634f17 | |||
| 5910961825 | |||
| 7a4e187745 | |||
| ebfdad8898 | |||
| c3db42bd76 | |||
| d36f8915ac | |||
| 6c6a02bcac | |||
| 42261b325b | |||
| 09e485572a | |||
| 491e083bb4 | |||
| 7f636a451c | |||
| 4656d1bce9 | |||
| 983e6e5fa9 | |||
| 244752c343 | |||
| f6e46d4a46 | |||
| 067e00b474 |
@@ -0,0 +1,114 @@
|
||||
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 }}
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- 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={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
@@ -0,0 +1,37 @@
|
||||
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 }}"
|
||||
+2
-1
@@ -59,4 +59,5 @@ data/
|
||||
meta/
|
||||
garage.toml
|
||||
|
||||
backend/docs/
|
||||
backend/docs/
|
||||
config.yaml
|
||||
+17
-7
@@ -1,30 +1,40 @@
|
||||
FROM node:25-alpine3.22 AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:25-alpine3.22 AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
|
||||
RUN npm ci
|
||||
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
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.4-alpine3.22 AS backend-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN go install github.com/swaggo/swag/cmd/swag@latest
|
||||
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 go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY backend .
|
||||
|
||||
RUN swag init
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
swag init
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o garage-ui .
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -o garage-ui .
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
.PHONY: help build up down restart logs clean ps health dev-build dev-up dev-down dev-restart dev-logs prod-up prod-down prod-restart prod-logs push stop
|
||||
|
||||
# Variables
|
||||
DOCKER_COMPOSE = docker-compose
|
||||
DOCKER_COMPOSE_DEV = docker-compose -f docker-compose.dev.yml
|
||||
DOCKER_COMPOSE_PROD = docker-compose -f docker-compose.yml
|
||||
IMAGE_NAME = noooste/garage-ui
|
||||
IMAGE_TAG = latest
|
||||
|
||||
## help: Show this help message
|
||||
help:
|
||||
@echo 'Usage:'
|
||||
@sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /'
|
||||
|
||||
## build: Build the Docker image locally
|
||||
build:
|
||||
docker build -t $(IMAGE_NAME):$(IMAGE_TAG) .
|
||||
|
||||
## build-no-cache: Build the Docker image without cache
|
||||
build-no-cache:
|
||||
docker build --no-cache -t $(IMAGE_NAME):$(IMAGE_TAG) .
|
||||
|
||||
## push: Push the Docker image to registry
|
||||
push: build
|
||||
docker push $(IMAGE_NAME):$(IMAGE_TAG)
|
||||
|
||||
## dev-build: Build and start development environment
|
||||
dev-build:
|
||||
$(DOCKER_COMPOSE_DEV) build
|
||||
|
||||
## dev-up: Start development environment
|
||||
dev-up:
|
||||
$(DOCKER_COMPOSE_DEV) up -d
|
||||
|
||||
## dev-down: Stop development environment
|
||||
dev-down:
|
||||
$(DOCKER_COMPOSE_DEV) down
|
||||
|
||||
## dev-restart: Restart development environment
|
||||
dev-restart: dev-down dev-up
|
||||
|
||||
## dev-logs: Show logs for development environment
|
||||
dev-logs:
|
||||
$(DOCKER_COMPOSE_DEV) logs -f
|
||||
|
||||
## dev-logs-ui: Show logs for garage-ui in development
|
||||
dev-logs-ui:
|
||||
$(DOCKER_COMPOSE_DEV) logs -f garage-ui
|
||||
|
||||
## dev-logs-garage: Show logs for garage in development
|
||||
dev-logs-garage:
|
||||
$(DOCKER_COMPOSE_DEV) logs -f garage
|
||||
|
||||
## dev-shell: Open shell in garage-ui container (development)
|
||||
dev-shell:
|
||||
$(DOCKER_COMPOSE_DEV) exec garage-ui sh
|
||||
|
||||
## prod-up: Start production environment
|
||||
prod-up:
|
||||
$(DOCKER_COMPOSE_PROD) up -d
|
||||
|
||||
## prod-down: Stop production environment
|
||||
prod-down:
|
||||
$(DOCKER_COMPOSE_PROD) down
|
||||
|
||||
## prod-restart: Restart production environment
|
||||
prod-restart: prod-down prod-up
|
||||
|
||||
## prod-logs: Show logs for production environment
|
||||
prod-logs:
|
||||
$(DOCKER_COMPOSE_PROD) logs -f
|
||||
|
||||
## prod-logs-ui: Show logs for garage-ui in production
|
||||
prod-logs-ui:
|
||||
$(DOCKER_COMPOSE_PROD) logs -f garage-ui
|
||||
|
||||
## prod-logs-garage: Show logs for garage in production
|
||||
prod-logs-garage:
|
||||
$(DOCKER_COMPOSE_PROD) logs -f garage
|
||||
|
||||
## prod-pull: Pull latest images for production
|
||||
prod-pull:
|
||||
$(DOCKER_COMPOSE_PROD) pull
|
||||
|
||||
## up: Start default (production) environment
|
||||
up: prod-up
|
||||
|
||||
## down: Stop all environments
|
||||
down:
|
||||
$(DOCKER_COMPOSE_PROD) down
|
||||
$(DOCKER_COMPOSE_DEV) down
|
||||
|
||||
## stop: Stop all containers
|
||||
stop:
|
||||
$(DOCKER_COMPOSE_PROD) stop
|
||||
$(DOCKER_COMPOSE_DEV) stop
|
||||
|
||||
## restart: Restart default (production) environment
|
||||
restart: prod-restart
|
||||
|
||||
## logs: Show logs for default (production) environment
|
||||
logs: prod-logs
|
||||
|
||||
## ps: Show running containers
|
||||
ps:
|
||||
docker ps -a --filter "name=garage"
|
||||
|
||||
## health: Check health status of containers
|
||||
health:
|
||||
@echo "=== Container Status ==="
|
||||
@docker ps --filter "name=garage" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
|
||||
## clean: Remove all containers, volumes, and images
|
||||
clean:
|
||||
$(DOCKER_COMPOSE_PROD) down -v --remove-orphans
|
||||
$(DOCKER_COMPOSE_DEV) down -v --remove-orphans
|
||||
docker system prune -f
|
||||
|
||||
## clean-volumes: Remove all volumes (WARNING: deletes data)
|
||||
clean-volumes:
|
||||
$(DOCKER_COMPOSE_PROD) down -v
|
||||
$(DOCKER_COMPOSE_DEV) down -v
|
||||
rm -rf ./meta ./data
|
||||
|
||||
## rebuild: Clean rebuild of development environment
|
||||
rebuild: dev-down dev-build dev-up
|
||||
|
||||
## install: Initial setup - create necessary directories
|
||||
install:
|
||||
@echo "Creating necessary directories..."
|
||||
@mkdir -p meta data
|
||||
@echo "Setup complete. Edit garage.toml and config.yaml before starting."
|
||||
|
||||
## update: Pull latest changes and rebuild
|
||||
update: prod-pull prod-restart
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -0,0 +1,673 @@
|
||||
# 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.
|
||||
|
||||
[](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
|
||||
[](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev/)
|
||||
[](https://nodejs.org/)
|
||||
[](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
|
||||
```
|
||||
|
||||
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: "garage:3900"
|
||||
region: "eu-west-1" # Ensure to match Garage region from garage.toml
|
||||
admin_endpoint: "http://garage:3903"
|
||||
admin_token: "your-admin-token-here"
|
||||
|
||||
# Authentication Configuration
|
||||
# You can enable one or both authentication methods
|
||||
auth:
|
||||
# Admin authentication (username/password)
|
||||
admin:
|
||||
enabled: false # Set to true to enable admin login
|
||||
username: "admin"
|
||||
password: "secure-password"
|
||||
|
||||
# OIDC authentication (Keycloak, Auth0, etc.)
|
||||
oidc:
|
||||
enabled: false # Set to true to enable OIDC login
|
||||
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_ADMIN_ENABLED=true
|
||||
GARAGE_UI_AUTH_ADMIN_USERNAME=admin
|
||||
GARAGE_UI_AUTH_ADMIN_PASSWORD=password
|
||||
GARAGE_UI_AUTH_OIDC_ENABLED=false
|
||||
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
|
||||
|
||||
Garage UI supports flexible authentication with the ability to enable one or both methods simultaneously:
|
||||
|
||||
### No Authentication
|
||||
|
||||
If neither admin nor OIDC authentication is enabled, the application runs without authentication - suitable for private networks or development.
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
admin:
|
||||
enabled: false
|
||||
oidc:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
### Admin Authentication
|
||||
|
||||
Simple username/password authentication using JWT tokens.
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
admin:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password: "your-secure-password"
|
||||
oidc:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
### OIDC (OpenID Connect) Authentication
|
||||
|
||||
Enterprise-grade authentication with providers like Keycloak, Auth0, Okta, etc.
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
admin:
|
||||
enabled: false
|
||||
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
|
||||
```
|
||||
|
||||
### Both Authentication Methods
|
||||
|
||||
You can enable both admin and OIDC authentication simultaneously. Users will be presented with both options on the login page:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
admin:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password: "your-secure-password"
|
||||
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
|
||||
cookie_secure: true
|
||||
```
|
||||
|
||||
**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: latest
|
||||
|
||||
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
|
||||
|
||||
config:
|
||||
auth:
|
||||
admin:
|
||||
enabled: false
|
||||
oidc:
|
||||
enabled: true
|
||||
client_id: garage-ui
|
||||
client_secret: secret
|
||||
issuer_url: 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.
|
||||
|
||||
---
|
||||
|
||||
## 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**
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
|
||||
// AuthService handles authentication operations
|
||||
type AuthService struct {
|
||||
config *config.AuthConfig
|
||||
authConfig *config.AuthConfig
|
||||
serverConfig *config.ServerConfig
|
||||
oidcProvider *oidc.Provider
|
||||
oidcVerifier *oidc.IDTokenVerifier
|
||||
oauth2Config *oauth2.Config
|
||||
@@ -31,19 +32,20 @@ type UserInfo struct {
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) {
|
||||
jwtService, err := NewJWTService()
|
||||
func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*AuthService, error) {
|
||||
jwtService, err := NewJWTServiceWithKey(authCfg.JWTPrivKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize JWT service: %w", err)
|
||||
}
|
||||
|
||||
service := &AuthService{
|
||||
config: cfg,
|
||||
jwtService: jwtService,
|
||||
authConfig: authCfg,
|
||||
serverConfig: serverCfg,
|
||||
jwtService: jwtService,
|
||||
}
|
||||
|
||||
// Initialize OIDC if enabled
|
||||
if cfg.Mode == "oidc" && cfg.OIDC.Enabled {
|
||||
if authCfg.OIDC.Enabled {
|
||||
if err := service.initOIDC(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OIDC: %w", err)
|
||||
}
|
||||
@@ -57,7 +59,7 @@ func (a *AuthService) initOIDC() error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create OIDC provider
|
||||
provider, err := oidc.NewProvider(ctx, a.config.OIDC.IssuerURL)
|
||||
provider, err := oidc.NewProvider(ctx, a.authConfig.OIDC.IssuerURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OIDC provider: %w", err)
|
||||
}
|
||||
@@ -66,18 +68,23 @@ func (a *AuthService) initOIDC() error {
|
||||
|
||||
// Create ID token verifier
|
||||
verifierConfig := &oidc.Config{
|
||||
ClientID: a.config.OIDC.ClientID,
|
||||
SkipIssuerCheck: a.config.OIDC.SkipIssuerCheck,
|
||||
SkipExpiryCheck: a.config.OIDC.SkipExpiryCheck,
|
||||
ClientID: a.authConfig.OIDC.ClientID,
|
||||
SkipIssuerCheck: a.authConfig.OIDC.SkipIssuerCheck,
|
||||
SkipExpiryCheck: a.authConfig.OIDC.SkipExpiryCheck,
|
||||
}
|
||||
a.oidcVerifier = provider.Verifier(verifierConfig)
|
||||
|
||||
// Construct redirect URL from server config
|
||||
// Use root_url if set, otherwise construct from protocol/domain
|
||||
redirectURL := a.serverConfig.RootURL + "/auth/oidc/callback"
|
||||
|
||||
// Create OAuth2 config
|
||||
a.oauth2Config = &oauth2.Config{
|
||||
ClientID: a.config.OIDC.ClientID,
|
||||
ClientSecret: a.config.OIDC.ClientSecret,
|
||||
ClientID: a.authConfig.OIDC.ClientID,
|
||||
ClientSecret: a.authConfig.OIDC.ClientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: a.config.OIDC.Scopes,
|
||||
Scopes: a.authConfig.OIDC.Scopes,
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -88,12 +95,12 @@ 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),
|
||||
[]byte(a.authConfig.Admin.Username),
|
||||
) == 1
|
||||
|
||||
passwordMatch := subtle.ConstantTimeCompare(
|
||||
[]byte(password),
|
||||
[]byte(a.config.Basic.Password),
|
||||
[]byte(a.authConfig.Admin.Password),
|
||||
) == 1
|
||||
|
||||
return usernameMatch && passwordMatch
|
||||
@@ -171,14 +178,14 @@ func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*Us
|
||||
|
||||
// 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),
|
||||
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
|
||||
Email: extractClaim(claims, a.authConfig.OIDC.EmailAttribute),
|
||||
Name: extractClaim(claims, a.authConfig.OIDC.NameAttribute),
|
||||
}
|
||||
|
||||
// Extract roles if configured
|
||||
if a.config.OIDC.RoleAttributePath != "" {
|
||||
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
|
||||
if a.authConfig.OIDC.RoleAttributePath != "" {
|
||||
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
@@ -207,14 +214,14 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
|
||||
|
||||
// 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),
|
||||
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
|
||||
Email: extractClaim(claims, a.authConfig.OIDC.EmailAttribute),
|
||||
Name: extractClaim(claims, a.authConfig.OIDC.NameAttribute),
|
||||
}
|
||||
|
||||
// Extract roles if configured
|
||||
if a.config.OIDC.RoleAttributePath != "" {
|
||||
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
|
||||
if a.authConfig.OIDC.RoleAttributePath != "" {
|
||||
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
@@ -222,12 +229,12 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
|
||||
|
||||
// IsAdmin checks if the user has admin role
|
||||
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
|
||||
if a.config.OIDC.AdminRole == "" {
|
||||
if a.authConfig.OIDC.AdminRole == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, role := range userInfo.Roles {
|
||||
if role == a.config.OIDC.AdminRole {
|
||||
if role == a.authConfig.OIDC.AdminRole {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -320,7 +327,7 @@ func (a *AuthService) ValidateAndConsumeState(token string) bool {
|
||||
|
||||
// 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)
|
||||
return a.jwtService.GenerateToken(userInfo, a.authConfig.OIDC.SessionMaxAge)
|
||||
}
|
||||
|
||||
// ValidateSessionToken validates a JWT session token and returns user info
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
@@ -14,9 +13,10 @@ import (
|
||||
)
|
||||
|
||||
type JWTService struct {
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey *rsa.PublicKey
|
||||
privateKey ed25519.PrivateKey
|
||||
publicKey ed25519.PublicKey
|
||||
stateStore *StateStore
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type StateStore struct {
|
||||
@@ -38,20 +38,53 @@ type SessionClaims struct {
|
||||
}
|
||||
|
||||
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 NewJWTServiceWithKey("")
|
||||
}
|
||||
|
||||
func NewJWTServiceWithKey(privateKeyPEM string) (*JWTService, error) {
|
||||
var privateKey ed25519.PrivateKey
|
||||
var publicKey ed25519.PublicKey
|
||||
var err error
|
||||
|
||||
if privateKeyPEM != "" {
|
||||
// Parse the provided PEM-encoded private key
|
||||
privateKey, err = parseEd25519PrivateKeyFromPEM(privateKeyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Ed25519 private key: %w", err)
|
||||
}
|
||||
publicKey = privateKey.Public().(ed25519.PublicKey)
|
||||
} else {
|
||||
// Generate a new Ed25519 key pair if no key is provided
|
||||
publicKey, privateKey, err = ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate Ed25519 key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &JWTService{
|
||||
privateKey: privateKey,
|
||||
publicKey: &privateKey.PublicKey,
|
||||
publicKey: publicKey,
|
||||
stateStore: &StateStore{
|
||||
states: make(map[string]StateData),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseEd25519PrivateKeyFromPEM(privateKeyPEM string) (ed25519.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode PEM block")
|
||||
}
|
||||
|
||||
// Check if it's raw Ed25519 private key bytes (64 bytes)
|
||||
if len(block.Bytes) == ed25519.PrivateKeySize {
|
||||
return ed25519.PrivateKey(block.Bytes), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid Ed25519 private key format: expected %d bytes, got %d",
|
||||
ed25519.PrivateKeySize, len(block.Bytes))
|
||||
}
|
||||
|
||||
func (j *JWTService) GenerateStateToken() (string, error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
@@ -105,6 +138,13 @@ func (j *JWTService) cleanupExpiredStates() {
|
||||
}
|
||||
|
||||
func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (string, error) {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
|
||||
if j.privateKey == nil {
|
||||
return "", fmt.Errorf("private key not initialized")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second)
|
||||
|
||||
@@ -120,7 +160,7 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
||||
tokenString, err := token.SignedString(j.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||
@@ -130,8 +170,15 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin
|
||||
}
|
||||
|
||||
func (j *JWTService) ValidateToken(tokenString string) (*SessionClaims, error) {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
|
||||
if j.publicKey == nil {
|
||||
return nil, fmt.Errorf("public key not initialized")
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &SessionClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return j.publicKey, nil
|
||||
@@ -149,15 +196,29 @@ func (j *JWTService) ValidateToken(tokenString string) (*SessionClaims, error) {
|
||||
}
|
||||
|
||||
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)
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
|
||||
if j.publicKey == nil {
|
||||
return "", fmt.Errorf("public key not initialized")
|
||||
}
|
||||
|
||||
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PUBLIC KEY",
|
||||
Bytes: pubKeyBytes,
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: []byte(j.publicKey),
|
||||
})
|
||||
|
||||
return string(pubKeyPEM), nil
|
||||
}
|
||||
|
||||
// GetPublicKeyBase64 returns the base64url-encoded public key for JWKS
|
||||
func (j *JWTService) GetPublicKeyBase64() (string, error) {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
|
||||
if j.publicKey == nil {
|
||||
return "", fmt.Errorf("public key not initialized")
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(j.publicKey), nil
|
||||
}
|
||||
|
||||
@@ -19,10 +19,17 @@ type Config struct {
|
||||
|
||||
// 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
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Environment string `mapstructure:"environment"`
|
||||
FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory
|
||||
Domain string `mapstructure:"domain"` // Domain name (e.g., garage-ui.example.com)
|
||||
Protocol string `mapstructure:"protocol"` // Protocol for internal communication (http/https)
|
||||
RootURL string `mapstructure:"root_url"` // Full external URL for redirects (e.g., https://garage-ui.example.com)
|
||||
MaxBodySize int64 `mapstructure:"max_body_size"` // Maximum request body size in bytes (default: 300MB)
|
||||
MaxHeaderSize int `mapstructure:"max_header_size"` // Maximum request header size in bytes (default: 1MB)
|
||||
ReadBufferSize int `mapstructure:"read_buffer_size"` // Read buffer size in bytes (default: 4KB)
|
||||
WriteBufferSize int `mapstructure:"write_buffer_size"` // Write buffer size in bytes (default: 4KB)
|
||||
}
|
||||
|
||||
// GarageConfig contains Garage S3 connection settings
|
||||
@@ -37,13 +44,14 @@ type GarageConfig struct {
|
||||
|
||||
// AuthConfig contains authentication configuration
|
||||
type AuthConfig struct {
|
||||
Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
|
||||
Basic BasicAuthConfig `mapstructure:"basic"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
Admin AdminAuthConfig `mapstructure:"admin"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
|
||||
}
|
||||
|
||||
// BasicAuthConfig contains basic authentication settings
|
||||
type BasicAuthConfig struct {
|
||||
// AdminAuthConfig contains admin authentication settings
|
||||
type AdminAuthConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
}
|
||||
@@ -107,8 +115,7 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetEnvPrefix("GARAGE_UI")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
|
||||
// Bind environment variables to config keys
|
||||
// This ensures env vars override config file values
|
||||
// Env vars override config file values
|
||||
bindEnvVars()
|
||||
|
||||
// Read the config file (optional - will use defaults and env vars if not found)
|
||||
@@ -139,6 +146,13 @@ func bindEnvVars() {
|
||||
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")
|
||||
viper.BindEnv("server.domain", "GARAGE_UI_SERVER_DOMAIN")
|
||||
viper.BindEnv("server.protocol", "GARAGE_UI_SERVER_PROTOCOL")
|
||||
viper.BindEnv("server.root_url", "GARAGE_UI_SERVER_ROOT_URL")
|
||||
viper.BindEnv("server.max_body_size", "GARAGE_UI_SERVER_MAX_BODY_SIZE")
|
||||
viper.BindEnv("server.max_header_size", "GARAGE_UI_SERVER_MAX_HEADER_SIZE")
|
||||
viper.BindEnv("server.read_buffer_size", "GARAGE_UI_SERVER_READ_BUFFER_SIZE")
|
||||
viper.BindEnv("server.write_buffer_size", "GARAGE_UI_SERVER_WRITE_BUFFER_SIZE")
|
||||
|
||||
// Garage config
|
||||
viper.BindEnv("garage.endpoint", "GARAGE_UI_GARAGE_ENDPOINT")
|
||||
@@ -149,9 +163,10 @@ func bindEnvVars() {
|
||||
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")
|
||||
viper.BindEnv("auth.admin.enabled", "GARAGE_UI_AUTH_ADMIN_ENABLED")
|
||||
viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME")
|
||||
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
|
||||
viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY")
|
||||
|
||||
// OIDC config
|
||||
viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED")
|
||||
@@ -208,28 +223,26 @@ func (c *Config) Validate() error {
|
||||
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 admin auth if enabled
|
||||
if c.Auth.Admin.Enabled {
|
||||
if c.Auth.Admin.Username == "" || c.Auth.Admin.Password == "" {
|
||||
return fmt.Errorf("admin auth username and password are required when admin auth is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate OIDC config if enabled
|
||||
if c.Auth.Mode == "oidc" {
|
||||
if c.Auth.OIDC.Enabled {
|
||||
if c.Auth.OIDC.ClientID == "" {
|
||||
return fmt.Errorf("oidc client_id is required when auth mode is 'oidc'")
|
||||
return fmt.Errorf("oidc client_id is required when oidc is enabled")
|
||||
}
|
||||
if c.Auth.OIDC.IssuerURL == "" {
|
||||
return fmt.Errorf("oidc issuer_url is required when auth mode is 'oidc'")
|
||||
return fmt.Errorf("oidc issuer_url is required when oidc is enabled")
|
||||
}
|
||||
if c.Server.RootURL == "" {
|
||||
return fmt.Errorf("server.root_url is required when oidc is enabled")
|
||||
}
|
||||
if len(c.Auth.OIDC.Scopes) == 0 {
|
||||
return fmt.Errorf("oidc scopes are required when auth mode is 'oidc'")
|
||||
return fmt.Errorf("oidc scopes are required when oidc is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication-related requests
|
||||
type AuthHandler struct {
|
||||
cfg *config.Config
|
||||
authService *auth.AuthService
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler(cfg *config.Config, authService *auth.AuthService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
cfg: cfg,
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthConfig returns the current authentication configuration
|
||||
// @Summary Get authentication configuration
|
||||
// @Description Returns the current auth configuration (admin and/or OIDC)
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{admin=object,oidc=object} "Auth config"
|
||||
// @Router /auth/config [get]
|
||||
func (h *AuthHandler) GetAuthConfig(c fiber.Ctx) error {
|
||||
response := fiber.Map{
|
||||
"admin": fiber.Map{
|
||||
"enabled": h.cfg.Auth.Admin.Enabled,
|
||||
},
|
||||
"oidc": fiber.Map{
|
||||
"enabled": h.cfg.Auth.OIDC.Enabled,
|
||||
},
|
||||
}
|
||||
|
||||
// Add provider name if OIDC is enabled
|
||||
if h.cfg.Auth.OIDC.Enabled {
|
||||
provider := h.cfg.Auth.OIDC.ProviderName
|
||||
if provider == "" {
|
||||
provider = "OIDC Provider"
|
||||
}
|
||||
response["oidc"].(fiber.Map)["provider"] = provider
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// LoginBasicRequest represents the basic auth login request
|
||||
type LoginBasicRequest struct {
|
||||
Username string `json:"username" validate:"required"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
// LoginAdmin handles admin authentication login
|
||||
// @Summary Admin auth login
|
||||
// @Description Authenticate with admin username and password, returns JWT token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param credentials body LoginBasicRequest true "Login credentials"
|
||||
// @Success 200 {object} object{success=bool,token=string,user=object} "Login successful"
|
||||
// @Failure 400 {object} models.APIResponse "Invalid request"
|
||||
// @Failure 401 {object} models.APIResponse "Invalid credentials"
|
||||
// @Router /auth/login [post]
|
||||
func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
|
||||
// Parse request body
|
||||
var req LoginBasicRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body"),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate credentials against admin config
|
||||
if req.Username != h.cfg.Auth.Admin.Username || req.Password != h.cfg.Auth.Admin.Password {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
|
||||
)
|
||||
}
|
||||
|
||||
// Create user info object
|
||||
userInfo := &auth.UserInfo{
|
||||
Username: req.Username,
|
||||
}
|
||||
|
||||
// Generate JWT session token
|
||||
sessionToken, err := h.authService.GenerateSessionToken(userInfo)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create session"),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"token": sessionToken,
|
||||
"user": fiber.Map{
|
||||
"username": userInfo.Username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetMe returns the current authenticated user's information
|
||||
// @Summary Get current user
|
||||
// @Description Returns information about the currently authenticated user
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 200 {object} object{success=bool,user=object} "User information"
|
||||
// @Failure 401 {object} models.APIResponse "Not authenticated"
|
||||
// @Router /auth/me [get]
|
||||
func (h *AuthHandler) GetMe(c fiber.Ctx) error {
|
||||
// Try to get user info from OIDC context
|
||||
userInfoInterface := c.Locals("userInfo")
|
||||
if userInfoInterface != nil {
|
||||
userInfo, ok := userInfoInterface.(*auth.UserInfo)
|
||||
if ok {
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"user": fiber.Map{
|
||||
"username": userInfo.Username,
|
||||
"email": userInfo.Email,
|
||||
"name": userInfo.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get username from basic auth context
|
||||
usernameInterface := c.Locals("username")
|
||||
if usernameInterface != nil {
|
||||
username, ok := usernameInterface.(string)
|
||||
if ok {
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"user": fiber.Map{
|
||||
"username": username,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Not authenticated"),
|
||||
)
|
||||
}
|
||||
@@ -54,19 +54,24 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get detailed bucket info from Admin API to retrieve object count and size
|
||||
detailedInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
// If we can't get detailed info, return basic info without stats
|
||||
buckets = append(buckets, models.BucketInfo{
|
||||
Name: bucketName,
|
||||
CreationDate: adminBucket.Created,
|
||||
Region: "",
|
||||
})
|
||||
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
|
||||
ObjectCount: &detailedInfo.Objects,
|
||||
Size: &detailedInfo.Bytes,
|
||||
}
|
||||
|
||||
buckets = append(buckets, bucketInfo)
|
||||
@@ -111,25 +116,12 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if bucket already exists
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, req.Name)
|
||||
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.StatusConflict).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"),
|
||||
)
|
||||
}
|
||||
|
||||
// Create the bucket
|
||||
createBucketReq := models.CreateBucketAdminRequest{
|
||||
GlobalAlias: &req.Name,
|
||||
}
|
||||
if bucketInfo, err = h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
|
||||
|
||||
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()),
|
||||
)
|
||||
|
||||
@@ -154,9 +154,14 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
|
||||
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
// Get bucket name from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
// Get object key from locals (set by route handler) or from params
|
||||
key, ok := c.Locals("objectKey").(string)
|
||||
if !ok || key == "" {
|
||||
key = c.Params("key")
|
||||
}
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
@@ -205,9 +210,14 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
// Get bucket name from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
// Get object key from locals (set by route handler) or from params
|
||||
key, ok := c.Locals("objectKey").(string)
|
||||
if !ok || key == "" {
|
||||
key = c.Params("key")
|
||||
}
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
@@ -262,9 +272,14 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
|
||||
func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
// Get bucket name from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
// Get object key from locals (set by route handler) or from params
|
||||
key, ok := c.Locals("objectKey").(string)
|
||||
if !ok || key == "" {
|
||||
key = c.Params("key")
|
||||
}
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
@@ -301,9 +316,14 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
|
||||
func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
// Get bucket name from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
// Get object key from locals (set by route handler) or from params
|
||||
key, ok := c.Locals("objectKey").(string)
|
||||
if !ok || key == "" {
|
||||
key = c.Params("key")
|
||||
}
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
|
||||
@@ -251,6 +251,41 @@ func (h *UserHandler) GetUser(c fiber.Ctx) error {
|
||||
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
|
||||
|
||||
@@ -8,95 +8,63 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthMiddleware returns a Fiber middleware for authentication
|
||||
// It handles different auth modes: none, basic, and OIDC
|
||||
// AuthMiddleware supports admin and OIDC authentication
|
||||
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" {
|
||||
// If no auth is enabled, allow all requests
|
||||
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Handle basic authentication
|
||||
if cfg.Mode == "basic" {
|
||||
return handleBasicAuth(c, authService)
|
||||
// Get Authorization header
|
||||
authHeader := c.Get("Authorization")
|
||||
|
||||
// Try admin auth if enabled and header is present
|
||||
if cfg.Admin.Enabled && authHeader != "" {
|
||||
// Check if it's a Bearer token (JWT from admin login)
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
token := authHeader[7:]
|
||||
|
||||
// Validate JWT session token
|
||||
userInfo, err := authService.ValidateSessionToken(token)
|
||||
if err == nil {
|
||||
// Valid admin token
|
||||
c.Locals("userInfo", userInfo)
|
||||
c.Locals("username", userInfo.Username)
|
||||
if userInfo.Email != "" {
|
||||
c.Locals("email", userInfo.Email)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OIDC authentication
|
||||
if cfg.Mode == "oidc" {
|
||||
return handleOIDCAuth(c, authService, &cfg.OIDC)
|
||||
// Try OIDC auth if enabled
|
||||
if cfg.OIDC.Enabled {
|
||||
sessionCookie := c.Cookies(cfg.OIDC.CookieName)
|
||||
if sessionCookie != "" {
|
||||
// Validate JWT session token from cookie
|
||||
userInfo, err := authService.ValidateSessionToken(sessionCookie)
|
||||
if err == nil {
|
||||
// Valid OIDC token
|
||||
c.Locals("userInfo", userInfo)
|
||||
c.Locals("username", userInfo.Username)
|
||||
c.Locals("email", userInfo.Email)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
// No valid authentication found
|
||||
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" {
|
||||
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"),
|
||||
)
|
||||
|
||||
@@ -41,8 +41,8 @@ type HealthResponse struct {
|
||||
// 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"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
ObjectCount *int64 `json:"objectCount,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
}
|
||||
@@ -55,12 +55,13 @@ type BucketListResponse struct {
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectListResponse represents a list of objects in a bucket
|
||||
@@ -120,7 +121,6 @@ type UserInfo struct {
|
||||
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"`
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
"Noooste/garage-ui/internal/middleware"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -39,6 +40,12 @@ func SetupRoutes(
|
||||
// Swagger documentation endpoint (no auth required)
|
||||
app.Get("/docs/*", swagger.HandlerDefault)
|
||||
|
||||
// Create auth handler
|
||||
authHandler := handlers.NewAuthHandler(cfg, authService)
|
||||
|
||||
// Auth configuration endpoint (always accessible, no auth required)
|
||||
app.Get("/auth/config", authHandler.GetAuthConfig)
|
||||
|
||||
// API v1 group
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
@@ -62,18 +69,81 @@ func SetupRoutes(
|
||||
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
|
||||
}
|
||||
|
||||
// Object-specific routes with wildcard key parameter (supports paths with slashes)
|
||||
// These need to be registered on the main app with auth middleware applied
|
||||
objectWildcardHandler := func(c fiber.Ctx) error {
|
||||
// Get the full path from wildcard parameter
|
||||
// Note: Fiber v3 does NOT automatically decode params, we need to do it manually
|
||||
path := c.Params("*")
|
||||
|
||||
// Decode the full path using QueryUnescape (handles %20, %2F, etc.)
|
||||
decodedPath, err := url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
// If decoding fails, use the original path
|
||||
decodedPath = path
|
||||
}
|
||||
|
||||
// Check if it's a metadata request
|
||||
if strings.HasSuffix(decodedPath, "/metadata") {
|
||||
// Remove /metadata suffix to get the actual key
|
||||
key := strings.TrimSuffix(decodedPath, "/metadata")
|
||||
c.Locals("objectKey", key)
|
||||
return objectHandler.GetObjectMetadata(c)
|
||||
}
|
||||
// Check if it's a presign request
|
||||
if strings.HasSuffix(decodedPath, "/presign") {
|
||||
// Remove /presign suffix to get the actual key
|
||||
key := strings.TrimSuffix(decodedPath, "/presign")
|
||||
c.Locals("objectKey", key)
|
||||
return objectHandler.GetPresignedURL(c)
|
||||
}
|
||||
// Otherwise, it's a regular object download
|
||||
c.Locals("objectKey", decodedPath)
|
||||
return objectHandler.GetObject(c)
|
||||
}
|
||||
|
||||
objectDeleteHandler := func(c fiber.Ctx) error {
|
||||
path := c.Params("*")
|
||||
|
||||
// Decode the full path using QueryUnescape
|
||||
key, err := url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
// If decoding fails, use the original path
|
||||
key = path
|
||||
}
|
||||
|
||||
c.Locals("objectKey", key)
|
||||
return objectHandler.DeleteObject(c)
|
||||
}
|
||||
|
||||
objectHeadHandler := func(c fiber.Ctx) error {
|
||||
path := c.Params("*")
|
||||
|
||||
// Decode the full path using QueryUnescape
|
||||
key, err := url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
// If decoding fails, use the original path
|
||||
key = path
|
||||
}
|
||||
|
||||
c.Locals("objectKey", key)
|
||||
return objectHandler.GetObjectMetadata(c)
|
||||
}
|
||||
|
||||
// Register with auth middleware
|
||||
app.Get("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectWildcardHandler)
|
||||
app.Delete("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectDeleteHandler)
|
||||
app.Head("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectHeadHandler)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -96,12 +166,22 @@ func SetupRoutes(
|
||||
monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
|
||||
}
|
||||
|
||||
// Admin auth login endpoint (only if admin is enabled)
|
||||
if cfg.Auth.Admin.Enabled {
|
||||
app.Post("/auth/login", authHandler.LoginAdmin)
|
||||
}
|
||||
|
||||
// Auth "me" endpoint (if any auth is enabled)
|
||||
if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled {
|
||||
app.Get("/auth/me", middleware.AuthMiddleware(&cfg.Auth, authService), authHandler.GetMe)
|
||||
}
|
||||
|
||||
// OIDC authentication routes (only if OIDC is enabled)
|
||||
if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled {
|
||||
authRoutes := app.Group("/auth")
|
||||
if cfg.Auth.OIDC.Enabled {
|
||||
oidcRoutes := app.Group("/auth/oidc")
|
||||
{
|
||||
// Login endpoint - redirects to OIDC provider
|
||||
authRoutes.Get("/login", func(c fiber.Ctx) error {
|
||||
oidcRoutes.Get("/login", func(c fiber.Ctx) error {
|
||||
state, err := authService.GenerateStateToken()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
@@ -119,7 +199,7 @@ func SetupRoutes(
|
||||
})
|
||||
|
||||
// Callback endpoint - handles OIDC redirect after login
|
||||
authRoutes.Get("/callback", func(c fiber.Ctx) error {
|
||||
oidcRoutes.Get("/callback", func(c fiber.Ctx) error {
|
||||
// Get and validate state token
|
||||
state := c.Query("state")
|
||||
if !authService.ValidateAndConsumeState(state) {
|
||||
@@ -179,14 +259,12 @@ func SetupRoutes(
|
||||
SameSite: cfg.Auth.OIDC.CookieSameSite,
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"user": userInfo,
|
||||
})
|
||||
// Redirect to frontend with success indicator
|
||||
return c.Redirect().To("/login?login=success")
|
||||
})
|
||||
|
||||
// Logout endpoint
|
||||
authRoutes.Post("/logout", func(c fiber.Ctx) error {
|
||||
oidcRoutes.Post("/logout", func(c fiber.Ctx) error {
|
||||
// Clear session cookie
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: cfg.Auth.OIDC.CookieName,
|
||||
@@ -199,33 +277,19 @@ func SetupRoutes(
|
||||
"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 {
|
||||
fmt.Println("Serving frontend from:", cfg.Server.FrontendPath)
|
||||
|
||||
// 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, "/auth") ||
|
||||
strings.HasPrefix(path, "/health") ||
|
||||
strings.HasPrefix(path, "/docs") {
|
||||
fmt.Println("API or health check route, skipping SPA fallback:", path)
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -31,17 +32,30 @@ func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
|
||||
}
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP request to the Admin API
|
||||
// doRequest performs an HTTP request to the Admin API with retry logic for connection refused errors
|
||||
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)
|
||||
var resp *azuretls.Response
|
||||
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var reqErr error
|
||||
resp, reqErr = 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)
|
||||
return reqErr
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// decodeResponse decodes a JSON response into the target structure
|
||||
@@ -210,7 +224,7 @@ func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAli
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
if err = decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
|
||||
+143
-71
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
@@ -24,9 +26,20 @@ type S3Service struct {
|
||||
// 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))
|
||||
@@ -39,8 +52,6 @@ func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -99,6 +110,7 @@ func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*min
|
||||
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)
|
||||
@@ -109,8 +121,15 @@ func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*min
|
||||
|
||||
// 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)
|
||||
var bucketInfos []minio.BucketInfo
|
||||
|
||||
// Call MinIO ListBuckets API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var listErr error
|
||||
bucketInfos, listErr = s.client.ListBuckets(ctx)
|
||||
return listErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list buckets: %w", err)
|
||||
}
|
||||
@@ -137,9 +156,12 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
|
||||
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,
|
||||
// Call MinIO MakeBucket API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
return client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
|
||||
Region: s.config.Region,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
|
||||
@@ -155,8 +177,11 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Call MinIO RemoveBucket API
|
||||
err = client.RemoveBucket(ctx, bucketName)
|
||||
// Call MinIO RemoveBucket API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
return client.RemoveBucket(ctx, bucketName)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -177,64 +202,72 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
||||
maxKeys = 1000
|
||||
}
|
||||
|
||||
// Use ListObjectsV2 for proper pagination support
|
||||
opts := minio.ListObjectsOptions{
|
||||
Prefix: prefix,
|
||||
Recursive: false,
|
||||
MaxKeys: maxKeys,
|
||||
StartAfter: continuationToken,
|
||||
UseV1: false,
|
||||
// Create Core client for low-level API access
|
||||
core := &minio.Core{Client: client}
|
||||
|
||||
// Use Core.ListObjectsV2 for proper pagination with continuation tokens
|
||||
result, err := core.ListObjectsV2(
|
||||
bucketName,
|
||||
prefix, // objectPrefix
|
||||
"", // startAfter (empty when using continuationToken)
|
||||
continuationToken, // continuationToken (proper S3 token)
|
||||
"/", // delimiter (for folder listing)
|
||||
maxKeys, // maxkeys
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
objects := make([]models.ObjectInfo, 0)
|
||||
prefixesMap := make(map[string]bool)
|
||||
// Process objects from result.Contents
|
||||
// Note: ListObjectsV2 doesn't return ContentType, so we need to fetch it separately
|
||||
objects := make([]models.ObjectInfo, len(result.Contents))
|
||||
|
||||
var lastKey string
|
||||
isTruncated := false
|
||||
itemCount := 0
|
||||
// Use goroutines to fetch ContentType concurrently for better performance
|
||||
type statResult struct {
|
||||
index int
|
||||
contentType string
|
||||
err error
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
statChan := make(chan statResult, len(result.Contents))
|
||||
|
||||
// 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
|
||||
}
|
||||
for i, obj := range result.Contents {
|
||||
go func(idx int, objKey string) {
|
||||
// Fetch object metadata to get ContentType
|
||||
stat, err := client.StatObject(ctx, bucketName, objKey, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
// If StatObject fails, we still include the object but without ContentType
|
||||
statChan <- statResult{index: idx, contentType: "", err: err}
|
||||
return
|
||||
}
|
||||
statChan <- statResult{index: idx, contentType: stat.ContentType, err: nil}
|
||||
}(i, obj.Key)
|
||||
|
||||
// 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
|
||||
// Initialize the object with basic info from ListObjectsV2
|
||||
objects[i] = models.ObjectInfo{
|
||||
Key: obj.Key,
|
||||
Size: obj.Size,
|
||||
LastModified: obj.LastModified,
|
||||
ETag: obj.ETag,
|
||||
StorageClass: obj.StorageClass,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert prefixes map to slice
|
||||
prefixList := make([]string, 0, len(prefixesMap))
|
||||
for p := range prefixesMap {
|
||||
prefixList = append(prefixList, p)
|
||||
// Collect results from goroutines
|
||||
for range result.Contents {
|
||||
res := <-statChan
|
||||
if res.err == nil {
|
||||
objects[res.index].ContentType = res.contentType
|
||||
}
|
||||
// If there was an error, ContentType remains empty, which is acceptable
|
||||
}
|
||||
close(statChan)
|
||||
|
||||
// Prepare next continuation token
|
||||
var nextToken string
|
||||
if isTruncated && lastKey != "" {
|
||||
nextToken = lastKey
|
||||
// Process folders from result.CommonPrefixes
|
||||
prefixList := make([]string, 0, len(result.CommonPrefixes))
|
||||
for _, p := range result.CommonPrefixes {
|
||||
prefixList = append(prefixList, p.Prefix)
|
||||
}
|
||||
|
||||
return &models.ObjectListResponse{
|
||||
@@ -242,8 +275,8 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
||||
Objects: objects,
|
||||
Prefixes: prefixList,
|
||||
Count: len(objects),
|
||||
IsTruncated: isTruncated,
|
||||
NextContinuationToken: nextToken,
|
||||
IsTruncated: result.IsTruncated,
|
||||
NextContinuationToken: result.NextContinuationToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -260,8 +293,15 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
|
||||
ContentType: contentType,
|
||||
}
|
||||
|
||||
// Call MinIO PutObject API
|
||||
info, err := client.PutObject(ctx, bucketName, key, body, -1, opts)
|
||||
var info minio.UploadInfo
|
||||
|
||||
// Call MinIO PutObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var uploadErr error
|
||||
info, uploadErr = client.PutObject(ctx, bucketName, key, body, -1, opts)
|
||||
return uploadErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
@@ -277,8 +317,15 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
|
||||
|
||||
// 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{})
|
||||
var object *minio.Object
|
||||
|
||||
// Call MinIO GetObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var getErr error
|
||||
object, getErr = s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
|
||||
return getErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
@@ -304,8 +351,11 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
|
||||
|
||||
// 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{})
|
||||
// Call MinIO RemoveObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
return 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)
|
||||
}
|
||||
@@ -321,7 +371,15 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
|
||||
return false, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
_, err = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
|
||||
var statErr error
|
||||
|
||||
// Call MinIO StatObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
_, statErr = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
|
||||
return statErr
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Check if error is "object not found"
|
||||
errResponse := minio.ToErrorResponse(err)
|
||||
@@ -341,7 +399,15 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
stat, err := client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
|
||||
var stat minio.ObjectInfo
|
||||
|
||||
// Call MinIO StatObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var statErr error
|
||||
stat, statErr = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
|
||||
return statErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
@@ -352,6 +418,8 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
|
||||
LastModified: stat.LastModified,
|
||||
ETag: stat.ETag,
|
||||
ContentType: stat.ContentType,
|
||||
StorageClass: stat.StorageClass,
|
||||
Metadata: stat.UserMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -402,8 +470,15 @@ func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string,
|
||||
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)
|
||||
var presignedURL *url.URL
|
||||
|
||||
// Generate presigned GET URL with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var presignErr error
|
||||
presignedURL, presignErr = client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil)
|
||||
return presignErr
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err)
|
||||
}
|
||||
@@ -421,9 +496,6 @@ type UploadResult struct {
|
||||
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
|
||||
|
||||
+40
-9
@@ -25,9 +25,6 @@ import (
|
||||
// @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
|
||||
|
||||
@@ -81,8 +78,19 @@ func main() {
|
||||
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)
|
||||
// Determine enabled auth methods for logging
|
||||
authMethods := []string{}
|
||||
if cfg.Auth.Admin.Enabled {
|
||||
authMethods = append(authMethods, "admin")
|
||||
}
|
||||
if cfg.Auth.OIDC.Enabled {
|
||||
authMethods = append(authMethods, "oidc")
|
||||
}
|
||||
if len(authMethods) == 0 {
|
||||
authMethods = append(authMethods, "none")
|
||||
}
|
||||
log.Printf("Initializing authentication service (enabled: %v)...", authMethods)
|
||||
authService, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize auth service: %v", err)
|
||||
}
|
||||
@@ -95,12 +103,35 @@ func main() {
|
||||
clusterHandler := handlers.NewClusterHandler(adminService)
|
||||
monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service)
|
||||
|
||||
// Set default values for buffer sizes if not configured
|
||||
maxBodySize := cfg.Server.MaxBodySize
|
||||
if maxBodySize == 0 {
|
||||
maxBodySize = 300 * 1024 * 1024 // 300MB default
|
||||
}
|
||||
maxHeaderSize := cfg.Server.MaxHeaderSize
|
||||
if maxHeaderSize == 0 {
|
||||
maxHeaderSize = 1 * 1024 * 1024 // 1MB default
|
||||
}
|
||||
readBufferSize := cfg.Server.ReadBufferSize
|
||||
if readBufferSize == 0 {
|
||||
readBufferSize = 4096 // 4KB default
|
||||
}
|
||||
writeBufferSize := cfg.Server.WriteBufferSize
|
||||
if writeBufferSize == 0 {
|
||||
writeBufferSize = 4096 // 4KB default
|
||||
}
|
||||
|
||||
log.Printf("Server limits - Max body: %d bytes (%.2fMB), Max header: %d bytes (%.2fKB)",
|
||||
maxBodySize, float64(maxBodySize)/(1024*1024),
|
||||
maxHeaderSize, float64(maxHeaderSize)/1024)
|
||||
|
||||
// Create Fiber app with configuration
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Garage UI Backend v" + version,
|
||||
//DisableStartupMessage: false,
|
||||
//EnablePrintRoutes: cfg.IsDevelopment(),
|
||||
ErrorHandler: customErrorHandler,
|
||||
AppName: "Garage UI Backend v" + version,
|
||||
BodyLimit: int(maxBodySize),
|
||||
ReadBufferSize: readBufferSize,
|
||||
WriteBufferSize: writeBufferSize,
|
||||
ErrorHandler: customErrorHandler,
|
||||
})
|
||||
|
||||
// Apply global middleware
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetryConfig configures retry behavior
|
||||
type RetryConfig struct {
|
||||
MaxRetries int
|
||||
InitialBackoff time.Duration
|
||||
MaxBackoff time.Duration
|
||||
BackoffFactor float64
|
||||
}
|
||||
|
||||
// DefaultRetryConfig returns default retry configuration
|
||||
func DefaultRetryConfig() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: 3,
|
||||
InitialBackoff: 100 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Second,
|
||||
BackoffFactor: 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
// IsConnectionRefused checks if the error is a connection refused error
|
||||
func IsConnectionRefused(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for connection refused error
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
// Check if it's a connection refused error
|
||||
if opErr.Op == "dial" || opErr.Op == "read" {
|
||||
var syscallErr *syscall.Errno
|
||||
if errors.As(opErr.Err, &syscallErr) {
|
||||
return *syscallErr == syscall.ECONNREFUSED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for "connection refused" in error message as fallback
|
||||
if errors.Is(err, syscall.ECONNREFUSED) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// RetryWithBackoff executes a function with exponential backoff on connection refused errors
|
||||
func RetryWithBackoff(ctx context.Context, config RetryConfig, fn func() error) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||
// Execute the function
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// If it's not a connection refused error, don't retry
|
||||
if !IsConnectionRefused(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// If this was the last attempt, return the error
|
||||
if attempt == config.MaxRetries {
|
||||
return fmt.Errorf("max retries (%d) exceeded: %w", config.MaxRetries, err)
|
||||
}
|
||||
|
||||
// Calculate backoff duration with exponential increase
|
||||
backoff := time.Duration(float64(config.InitialBackoff) * pow(config.BackoffFactor, float64(attempt)))
|
||||
if backoff > config.MaxBackoff {
|
||||
backoff = config.MaxBackoff
|
||||
}
|
||||
|
||||
// Wait with context cancellation support
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("context cancelled during retry: %w", ctx.Err())
|
||||
case <-time.After(backoff):
|
||||
// Continue to next attempt
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// pow is a simple integer exponentiation helper
|
||||
func pow(base float64, exp float64) float64 {
|
||||
result := 1.0
|
||||
for i := 0; i < int(exp); i++ {
|
||||
result *= base
|
||||
}
|
||||
return result
|
||||
}
|
||||
+24
-7
@@ -5,29 +5,46 @@ server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
environment: "development" # development, production
|
||||
domain: "localhost" # Domain name for the application
|
||||
protocol: "http" # Protocol for internal communication (http/https)
|
||||
root_url: "http://localhost:8080" # Full external URL for OAuth2 redirects (adjust for production)
|
||||
|
||||
# Request size limits (in bytes)
|
||||
max_body_size: 314572800 # 300MB - Maximum request body size (increase for large file uploads)
|
||||
max_header_size: 1048576 # 1MB - Maximum request header size
|
||||
read_buffer_size: 4096 # 4KB - Read buffer size
|
||||
write_buffer_size: 4096 # 4KB - Write buffer size
|
||||
|
||||
# Garage S3 Configuration
|
||||
garage:
|
||||
endpoint: "http://localhost:3900" # Garage S3 API endpoint
|
||||
region: "eu-west-1" # S3 region (can be any value for Garage)
|
||||
region: "eu-west-1" # S3 region (ensure it matches Garage S3 configuration)
|
||||
|
||||
# Garage Admin API configuration
|
||||
admin_endpoint: "http://localhost:3903" # Garage Admin API endpoint
|
||||
admin_token: "changeme" # Admin API bearer token
|
||||
|
||||
# Authentication Configuration
|
||||
# You can enable one or both authentication methods
|
||||
auth:
|
||||
# Auth mode: "none", "basic", or "oidc"
|
||||
mode: "none"
|
||||
# JWT Configuration
|
||||
# Ed25519 private key in PEM format for JWT token signing
|
||||
# If not specified, a new key will be generated on each startup (tokens won't persist across restarts)
|
||||
# Generate with: openssl genpkey -algorithm ED25519 -out jwt-key.pem
|
||||
# The key is a 64-byte Ed25519 private key
|
||||
jwt_private_key: "" # Leave empty to auto-generate, or provide PEM-encoded Ed25519 private key
|
||||
|
||||
# Basic Authentication (only used when mode = "basic")
|
||||
basic:
|
||||
# Admin Authentication (username/password)
|
||||
admin:
|
||||
enabled: false # Set to true to enable admin login
|
||||
username: "admin"
|
||||
password: "changeme"
|
||||
|
||||
# OIDC Configuration (only used when mode = "oidc")
|
||||
# OIDC Configuration
|
||||
# NOTE: When OIDC is enabled, server.root_url is required for OAuth2 redirects
|
||||
# The redirect URL will be automatically constructed as: {root_url}/auth/oidc/callback
|
||||
oidc:
|
||||
enabled: true
|
||||
enabled: false # Set to true to enable OIDC login
|
||||
provider_name: "Keycloak"
|
||||
client_id: "garage-ui"
|
||||
client_secret: "your-client-secret"
|
||||
|
||||
+10
-2
@@ -1,7 +1,7 @@
|
||||
---
|
||||
services:
|
||||
garage:
|
||||
image: dxflrs/garage:v2.0.0
|
||||
image: dxflrs/garage:v2.1.0
|
||||
container_name: garage
|
||||
volumes:
|
||||
- ./garage.toml:/etc/garage.toml
|
||||
@@ -26,7 +26,7 @@ services:
|
||||
- garage
|
||||
environment:
|
||||
# Garage S3 Configuration
|
||||
GARAGE_UI_GARAGE_ENDPOINT: "http://garage:3900"
|
||||
GARAGE_UI_GARAGE_ENDPOINT: "garage:3900"
|
||||
GARAGE_UI_GARAGE_ADMIN_ENDPOINT: "http://garage:3903"
|
||||
|
||||
# Server Configuration
|
||||
@@ -34,6 +34,14 @@ services:
|
||||
GARAGE_UI_SERVER_PORT: "8080"
|
||||
GARAGE_UI_SERVER_ENVIRONMENT: "production"
|
||||
|
||||
# JWT Configuration
|
||||
# IMPORTANT: Replace this with your own Ed25519 private key in production!
|
||||
# Generate with: openssl genpkey -algorithm ED25519
|
||||
GARAGE_UI_AUTH_JWT_PRIVATE_KEY: |
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS
|
||||
-----END PRIVATE KEY-----
|
||||
|
||||
# Logging
|
||||
GARAGE_UI_LOGGING_LEVEL: "info"
|
||||
GARAGE_UI_LOGGING_FORMAT: "json"
|
||||
|
||||
Generated
+569
-739
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
+27
-1
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import {BrowserRouter, Route, Routes} from 'react-router-dom';
|
||||
import {QueryClientProvider} from '@tanstack/react-query';
|
||||
import {ThemeProvider, useTheme} from '@/components/theme-provider';
|
||||
@@ -6,8 +7,13 @@ import {Dashboard} from '@/pages/Dashboard';
|
||||
import {Buckets} from '@/pages/Buckets';
|
||||
import {Cluster} from '@/pages/Cluster';
|
||||
import {AccessControl} from '@/pages/AccessControl';
|
||||
import {Login} from '@/pages/Login';
|
||||
import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView';
|
||||
import {Toaster} from 'sonner';
|
||||
import {queryClient} from '@/lib/query-client';
|
||||
import {useAuthStore} from '@/store/auth-store';
|
||||
import {ProtectedRoute} from '@/components/auth/ProtectedRoute';
|
||||
import {LoadingSpinner} from '@/components/auth/LoadingSpinner';
|
||||
|
||||
function ThemedToaster() {
|
||||
const { theme } = useTheme();
|
||||
@@ -16,14 +22,34 @@ function ThemedToaster() {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { initialize, isLoading } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system" storageKey="Noooste/garage-ui-theme">
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="buckets" element={<Buckets />} />
|
||||
<Route path="buckets/:bucketName/objects/*" element={<ObjectDetailsView />} />
|
||||
<Route path="cluster" element={<Cluster />} />
|
||||
<Route path="access" element={<AccessControl />} />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import type { AuthConfig } from '@/types/auth';
|
||||
|
||||
interface BasicLoginFormProps {
|
||||
showOIDC?: boolean;
|
||||
config?: AuthConfig | null;
|
||||
}
|
||||
|
||||
export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { loginAdmin, loginOIDC } = useAuthStore();
|
||||
|
||||
const returnUrl = searchParams.get('returnUrl') || '/';
|
||||
const providerName = config?.oidc?.provider || 'OIDC Provider';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await loginAdmin(username, password);
|
||||
// Navigate to return URL on success
|
||||
navigate(decodeURIComponent(returnUrl));
|
||||
} catch (error) {
|
||||
// Error is already handled by the store and toast
|
||||
console.error('Login failed:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<img
|
||||
src="/garage.png"
|
||||
alt="Garage Logo"
|
||||
className="h-16 w-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">
|
||||
Welcome to Garage UI
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="text-sm font-medium">Username</label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Enter your username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">Password</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !username || !password}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{showOIDC && (
|
||||
<div className="mt-4">
|
||||
<div className="relative mb-4">
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="bg-card px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={loginOIDC}
|
||||
>
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
Sign in with {providerName}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LogIn } from 'lucide-react';
|
||||
|
||||
export function OIDCLoginView() {
|
||||
const { config, loginOIDC } = useAuthStore();
|
||||
|
||||
const providerName = config?.oidc.provider || 'OIDC Provider';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<img
|
||||
src="/garage.png"
|
||||
alt="Garage Logo"
|
||||
className="h-16 w-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">Sign in to Garage UI</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Authenticate using your {providerName} account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={loginOIDC}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<LogIn className="mr-2 h-5 w-5" />
|
||||
Continue with {providerName}
|
||||
</Button>
|
||||
<p className="mt-4 text-center text-xs text-muted-foreground">
|
||||
You will be redirected to {providerName} to complete the sign-in process
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading, config } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
// If no auth is enabled, always allow access
|
||||
if (config && !config.admin.enabled && !config.oidc.enabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// If not authenticated, redirect to login with return URL
|
||||
if (!isAuthenticated) {
|
||||
const returnUrl = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
|
||||
import { formatBytes, formatDate } from '@/lib/utils';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import type { Bucket } from '@/types';
|
||||
|
||||
interface BucketListViewProps {
|
||||
|
||||
@@ -6,9 +6,10 @@ import {Header} from '@/components/layout/header';
|
||||
import {ObjectsTable} from './ObjectsTable';
|
||||
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
|
||||
import {DeleteObjectDialog} from './DeleteObjectDialog';
|
||||
import {UploadProgress} from './UploadProgress';
|
||||
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload} from 'lucide-react';
|
||||
import {getBreadcrumbs} from '@/lib/file-utils';
|
||||
import type {S3Object} from '@/types';
|
||||
import type {S3Object, UploadTask} from '@/types';
|
||||
|
||||
interface ObjectBrowserViewProps {
|
||||
bucketName: string;
|
||||
@@ -23,6 +24,7 @@ interface ObjectBrowserViewProps {
|
||||
onNavigateToFolder: (path: string) => void;
|
||||
onBackToBuckets: () => void;
|
||||
onUploadFiles: (files: File[]) => Promise<boolean>;
|
||||
uploadTasks: UploadTask[];
|
||||
onDeleteObject: (key: string) => Promise<boolean>;
|
||||
onDeleteMultipleObjects: (keys: string[]) => Promise<boolean>;
|
||||
onCreateDirectory: (name: string) => Promise<boolean>;
|
||||
@@ -48,6 +50,7 @@ export function ObjectBrowserView({
|
||||
onNavigateToFolder,
|
||||
onBackToBuckets,
|
||||
onUploadFiles,
|
||||
uploadTasks,
|
||||
onDeleteObject,
|
||||
onDeleteMultipleObjects,
|
||||
onCreateDirectory,
|
||||
@@ -233,7 +236,7 @@ export function ObjectBrowserView({
|
||||
</div>
|
||||
|
||||
{/* Upload Zone */}
|
||||
{showUploadZone && (
|
||||
{showUploadZone && uploadTasks.length === 0 && (
|
||||
<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">
|
||||
@@ -312,6 +315,9 @@ export function ObjectBrowserView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Progress */}
|
||||
{uploadTasks.length > 0 && <UploadProgress tasks={uploadTasks} />}
|
||||
|
||||
{/* Objects Table with Drag & Drop */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
@@ -344,6 +350,7 @@ export function ObjectBrowserView({
|
||||
)}
|
||||
|
||||
<ObjectsTable
|
||||
bucketName={bucketName}
|
||||
objects={objects}
|
||||
currentPath={currentPath}
|
||||
searchQuery={searchQuery}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import type { ObjectMetadata } from '@/types';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft, Download, Trash, Copy, File } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
|
||||
export function ObjectDetailsView() {
|
||||
const navigate = useNavigate();
|
||||
const { bucketName, '*': encodedObjectKey } = useParams();
|
||||
// Decode the object key from the URL
|
||||
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
|
||||
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bucketName || !objectKey) {
|
||||
setError('Bucket name and object key are required');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await objectsApi.getMetadata(bucketName, objectKey);
|
||||
setMetadata(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load object metadata');
|
||||
console.error('Failed to fetch object metadata:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetadata();
|
||||
}, [bucketName, objectKey]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!bucketName || !objectKey) return;
|
||||
|
||||
try {
|
||||
const blob = await objectsApi.get(bucketName, objectKey);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = objectKey.split('/').pop() || 'download';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success('Download started');
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!bucketName || !objectKey) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete "${objectKey}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await objectsApi.delete(bucketName, objectKey);
|
||||
toast.success('Object deleted successfully');
|
||||
handleBackNavigation();
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackNavigation = () => {
|
||||
if (!bucketName) return;
|
||||
|
||||
// Navigate back to the bucket explorer with the appropriate prefix
|
||||
// Extract the folder path from the object key (everything before the last /)
|
||||
const folderPath = objectKey?.split('/').slice(0, -1).join('/') || '';
|
||||
const prefix = folderPath ? `${folderPath}/` : '';
|
||||
|
||||
// Navigate to the bucket view with the correct prefix
|
||||
navigate(`/buckets?bucket=${encodeURIComponent(bucketName)}${prefix ? `&prefix=${encodeURIComponent(prefix)}` : ''}`);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Object Details" />
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">Loading object details...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !metadata) {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Object Details" />
|
||||
<div className="p-4 sm:p-6">
|
||||
<Button variant="outline" onClick={handleBackNavigation} className="mb-4">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-500">{error || 'Object not found'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = objectKey?.split('/').pop() || objectKey || '';
|
||||
const pathParts = objectKey?.split('/').filter(part => part) || [];
|
||||
const parentPath = pathParts.slice(0, -1).join('/');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title={fileName} />
|
||||
<div className="p-4 sm:p-6 space-y-6">
|
||||
{/* Back Button and Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handleBackNavigation}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-500 text-red-500 hover:bg-red-500/5"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Name Header */}
|
||||
<div className="flex items-start gap-3 p-4 border-b border-border bg-card rounded-t-lg">
|
||||
<div className="mt-1">
|
||||
<File className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<h2 className="text-lg font-medium text-foreground break-all">
|
||||
{parentPath && (
|
||||
<span className="text-muted-foreground font-mono">/{parentPath}/</span>
|
||||
)}
|
||||
{fileName}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => copyToClipboard(metadata.key)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Object Details Section */}
|
||||
<div className="border border-border rounded-lg bg-card">
|
||||
<div className="p-6 border-b border-border">
|
||||
<h3 className="text-base font-semibold text-foreground">Object Details</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
|
||||
<div className="text-sm font-medium text-muted-foreground">Date Created</div>
|
||||
<div className="sm:col-span-2 text-sm text-foreground">
|
||||
{formatDate(metadata.lastModified)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
|
||||
<div className="text-sm font-medium text-muted-foreground">Type</div>
|
||||
<div className="sm:col-span-2 text-sm text-foreground">
|
||||
{metadata.contentType || 'application/octet-stream'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
|
||||
<div className="text-sm font-medium text-muted-foreground">Storage Class</div>
|
||||
<div className="sm:col-span-2 text-sm text-foreground">
|
||||
{metadata.storageClass || 'Standard'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
|
||||
<div className="text-sm font-medium text-muted-foreground">Size</div>
|
||||
<div className="sm:col-span-2 text-sm text-foreground">
|
||||
{formatBytes(metadata.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Metadata Section */}
|
||||
{metadata.metadata && Object.keys(metadata.metadata).length > 0 && (
|
||||
<div className="border border-border rounded-lg bg-card">
|
||||
<div className="p-6 border-b border-border">
|
||||
<h3 className="text-base font-semibold text-foreground">Custom Metadata</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/30">
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Key
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{Object.entries(metadata.metadata).map(([key, value]) => (
|
||||
<tr key={key} className="hover:bg-muted/30">
|
||||
<td className="px-6 py-4 text-sm font-medium text-foreground break-all">
|
||||
{key}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-foreground break-all">{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Object Preview Section */}
|
||||
<div className="border border-border rounded-lg bg-card">
|
||||
<div className="p-6 border-b border-border">
|
||||
<h3 className="text-base font-semibold text-foreground">Object Preview</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-muted-foreground">No preview available</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
@@ -11,13 +12,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {ChevronLeft, ChevronRight, Download, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
|
||||
import {ChevronLeft, ChevronRight, Download, Eye, 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 {formatBytes, formatRelativeTime} from '@/lib/file-utils';
|
||||
import type {S3Object} from '@/types';
|
||||
|
||||
interface ObjectsTableProps {
|
||||
bucketName: string;
|
||||
objects: S3Object[];
|
||||
currentPath: string;
|
||||
searchQuery: string;
|
||||
@@ -41,6 +42,7 @@ type SortColumn = 'name' | 'size' | 'modified';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export function ObjectsTable({
|
||||
bucketName,
|
||||
objects,
|
||||
currentPath,
|
||||
searchQuery,
|
||||
@@ -59,6 +61,7 @@ export function ObjectsTable({
|
||||
initialPageToken,
|
||||
initialItemsPerPage,
|
||||
}: ObjectsTableProps) {
|
||||
const navigate = useNavigate();
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
|
||||
@@ -117,16 +120,22 @@ export function ObjectsTable({
|
||||
return sorted;
|
||||
};
|
||||
|
||||
// Effect 1: Apply client-side filtering and sorting (NO pagination reset)
|
||||
useEffect(() => {
|
||||
const filtered = objects.filter((obj) =>
|
||||
obj.key.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const sorted = sortObjects(filtered);
|
||||
setFilteredObjects(sorted);
|
||||
// Reset pagination when path/search changes
|
||||
|
||||
// Do NOT reset pagination - search/sort are client-side operations
|
||||
}, [searchQuery, objects, sortColumn, sortDirection]);
|
||||
|
||||
// Effect 2: Reset pagination ONLY on path navigation
|
||||
useEffect(() => {
|
||||
setPageTokens([undefined]);
|
||||
setCurrentPageIndex(0);
|
||||
}, [searchQuery, objects, sortColumn, sortDirection, currentPath]);
|
||||
}, [currentPath]);
|
||||
|
||||
// Update page tokens when we get a new next token
|
||||
useEffect(() => {
|
||||
@@ -273,14 +282,17 @@ export function ObjectsTable({
|
||||
{obj.key.replace(currentPath, '').replace('/', '')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-medium">
|
||||
<button
|
||||
onClick={() => navigate(`/buckets/${bucketName}/objects/${encodeURIComponent(obj.key)}`)}
|
||||
className="font-medium cursor-pointer hover:underline hover:text-primary"
|
||||
>
|
||||
{obj.key.replace(currentPath, '')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
{obj.isFolder ? 'Directory' : getFileType(obj.key.replace(currentPath, ''))}
|
||||
{obj.isFolder ? 'Directory' : (obj.contentType || 'application/octet-stream')}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{obj.storageClass && (
|
||||
@@ -349,6 +361,10 @@ export function ObjectsTable({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/buckets/${bucketName}/objects/${encodeURIComponent(obj.key)}`)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { CheckCircle, Upload, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { UploadTask } from '@/types';
|
||||
|
||||
interface UploadProgressProps {
|
||||
tasks: UploadTask[];
|
||||
}
|
||||
|
||||
export function UploadProgress({ tasks }: UploadProgressProps) {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
const completedCount = tasks.filter(t => t.status === 'completed').length;
|
||||
const errorCount = tasks.filter(t => t.status === 'error').length;
|
||||
const totalCount = tasks.length;
|
||||
const processedCount = completedCount + errorCount;
|
||||
const allDone = processedCount === totalCount;
|
||||
|
||||
// Find currently uploading file
|
||||
const currentFile = tasks.find(t => t.status === 'uploading');
|
||||
const currentFileName = currentFile?.key.split('/').pop() || currentFile?.key || 'Processing...';
|
||||
|
||||
// File-based progress plus contribution from current upload
|
||||
const baseProgress = (processedCount / totalCount) * 100;
|
||||
const currentFileContribution = currentFile
|
||||
? (currentFile.progress / 100) * (1 / totalCount) * 100
|
||||
: 0;
|
||||
const overallProgress = Math.min(baseProgress + currentFileContribution, 100);
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20 shadow-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{/* Header with icon and status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg flex-shrink-0 ${
|
||||
allDone
|
||||
? 'bg-green-500/10 dark:bg-green-500/20'
|
||||
: errorCount > 0
|
||||
? 'bg-yellow-500/10 dark:bg-yellow-500/20'
|
||||
: 'bg-primary/10 dark:bg-primary/20'
|
||||
}`}>
|
||||
{allDone ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-500" />
|
||||
) : errorCount > 0 ? (
|
||||
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-500" />
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 text-primary animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-sm">
|
||||
{allDone ? 'Upload Complete' : 'Uploading Files'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{processedCount} of {totalCount} files
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className={`text-2xl font-bold tabular-nums ${
|
||||
allDone ? 'text-green-600 dark:text-green-500' : 'text-primary'
|
||||
}`}>
|
||||
{Math.round(overallProgress)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar with gradient */}
|
||||
<div className="space-y-2">
|
||||
{!allDone && currentFile && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Upload className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="truncate flex-1" title={currentFileName}>
|
||||
{currentFileName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-full bg-secondary rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ease-out relative bg-green-500 dark:bg-green-600`}
|
||||
style={{ width: `${overallProgress}%` }}
|
||||
>
|
||||
{/* Animated shimmer effect */}
|
||||
{!allDone && overallProgress > 0 && (
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 dark:via-white/25 to-transparent animate-shimmer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error indicator with icon */}
|
||||
{errorCount > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs bg-red-500/10 dark:bg-red-500/20 text-red-700 dark:text-red-400 rounded-md px-3 py-2 border border-red-200 dark:border-red-900/50">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
{errorCount} file{errorCount > 1 ? 's' : ''} failed to upload
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success message */}
|
||||
{allDone && errorCount === 0 && (
|
||||
<div className="flex items-center gap-2 text-xs bg-green-500/10 dark:bg-green-500/20 text-green-700 dark:text-green-400 rounded-md px-3 py-2 border border-green-200 dark:border-green-900/50">
|
||||
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>All files uploaded successfully</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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 {formatBytes} from '@/lib/file-utils';
|
||||
import {chartColorPalette, getTextColor, getTooltipStyle} from '@/lib/chart-colors';
|
||||
|
||||
interface BucketUsageChartProps {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import {Link, useLocation} from 'react-router-dom';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {Database, Key, LayoutDashboard, Server} from 'lucide-react';
|
||||
import {Database, Key, LayoutDashboard, LogOut, Server, User} from 'lucide-react';
|
||||
import {useAuthStore} from '@/store/auth-store';
|
||||
import {Button} from '@/components/ui/button';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
@@ -38,6 +40,11 @@ interface SidebarProps {
|
||||
|
||||
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const { user, config, logout } = useAuthStore();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -76,17 +83,30 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
);
|
||||
})}
|
||||
</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>*/}
|
||||
{config && (config.admin.enabled || config.oidc.enabled) && user && (
|
||||
<div className="border-t p-4 space-y-2">
|
||||
<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">
|
||||
<User className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="text-sm font-medium truncate">{user.name || user.username}</p>
|
||||
{user.email && (
|
||||
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { useDashboardData } from './useApi';
|
||||
export { useBuckets } from './useBuckets';
|
||||
export { useDashboardData, useBuckets } from './useApi';
|
||||
export { useBucketObjects } from './useBucketObjects';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import type { S3Object } from '@/types';
|
||||
import type { S3Object, UploadTask } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useBucketObjects(bucketName: string | null, currentPath: string = '') {
|
||||
@@ -14,6 +14,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [previousPath, setPreviousPath] = useState<string>(currentPath);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
|
||||
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
|
||||
if (!bucketName) return;
|
||||
@@ -57,41 +58,103 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
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);
|
||||
// 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]);
|
||||
}
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize upload tasks
|
||||
const tasks: UploadTask[] = files.map((file, index) => {
|
||||
const relativePath = (file as any).webkitRelativePath || file.name;
|
||||
const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
|
||||
return {
|
||||
id: `${Date.now()}-${index}`,
|
||||
file,
|
||||
key,
|
||||
bucket: bucketName,
|
||||
progress: 0,
|
||||
status: 'pending' as const,
|
||||
};
|
||||
});
|
||||
|
||||
setUploadTasks(tasks);
|
||||
|
||||
// Upload files with progress tracking and error handling
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// Upload files one by one
|
||||
const concurrency = 1;
|
||||
const uploadPromises: Promise<void>[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i += concurrency) {
|
||||
const batch = tasks.slice(i, Math.min(i + concurrency, tasks.length));
|
||||
|
||||
const batchPromises = batch.map(async (task) => {
|
||||
try {
|
||||
// Update task status to uploading
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'uploading' as const } : t
|
||||
));
|
||||
|
||||
await objectsApi.upload(bucketName, task.key, task.file, (progress) => {
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, progress } : t
|
||||
));
|
||||
});
|
||||
|
||||
// Update task status to completed
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
|
||||
));
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
// Update task status to error but continue with other uploads
|
||||
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t
|
||||
));
|
||||
errorCount++;
|
||||
console.error(`Failed to upload ${task.key}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
uploadPromises.push(...batchPromises);
|
||||
await Promise.all(batchPromises);
|
||||
}
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
// Show summary toast
|
||||
if (errorCount === 0) {
|
||||
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})`);
|
||||
toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''} from ${folders.size} folder${folders.size > 1 ? 's' : ''} (${folderNames})`);
|
||||
} else {
|
||||
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''}`);
|
||||
toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return false;
|
||||
} else if (successCount > 0) {
|
||||
toast.warning(`Uploaded ${successCount} file${successCount > 1 ? 's' : ''}, ${errorCount} failed`);
|
||||
} else {
|
||||
toast.error(`Failed to upload ${errorCount} file${errorCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
// Clear upload tasks after a delay
|
||||
setTimeout(() => {
|
||||
setUploadTasks([]);
|
||||
}, 3000);
|
||||
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return successCount > 0;
|
||||
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
|
||||
|
||||
const deleteObject = useCallback(async (key: string) => {
|
||||
@@ -160,6 +223,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
setItemsPerPage,
|
||||
fetchObjects,
|
||||
uploadFiles,
|
||||
uploadTasks,
|
||||
deleteObject,
|
||||
deleteMultipleObjects,
|
||||
createDirectory,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+101
-6
@@ -15,6 +15,13 @@ import type {
|
||||
S3Object,
|
||||
StorageMetrics,
|
||||
} from '@/types';
|
||||
import type { AuthUser } from '@/types/auth';
|
||||
|
||||
// Helper function to encode object keys for URLs
|
||||
// Encodes the entire key including slashes to ensure proper handling of special characters
|
||||
const encodeObjectKey = (key: string): string => {
|
||||
return encodeURIComponent(key);
|
||||
};
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -23,6 +30,14 @@ const api = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Separate axios instance for auth endpoints (which are not under /api)
|
||||
const authApiClient = axios.create({
|
||||
baseURL: '/auth',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (token) {
|
||||
@@ -31,6 +46,14 @@ api.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
authApiClient.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
|
||||
@@ -50,6 +73,19 @@ api.interceptors.response.use(
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// Handle 401 Unauthorized - redirect to login
|
||||
if (error.response?.status === 401) {
|
||||
// Clear auth token
|
||||
localStorage.removeItem('auth-token');
|
||||
|
||||
// Only redirect if not already on login page
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Handle axios errors
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
@@ -84,6 +120,44 @@ api.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
getConfig: async () => {
|
||||
const response = await authApiClient.get<{
|
||||
admin: { enabled: boolean };
|
||||
oidc: { enabled: boolean; provider?: string };
|
||||
}>('/config');
|
||||
return response;
|
||||
},
|
||||
|
||||
loginAdmin: async (username: string, password: string) => {
|
||||
const response = await authApiClient.post<{ success: boolean; token: string; user: AuthUser }>('/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
me: async () => {
|
||||
const response = await authApiClient.get<{ success: boolean; user: AuthUser }>('/me');
|
||||
return response;
|
||||
},
|
||||
|
||||
logoutAdmin: async () => {
|
||||
// For admin, just clear local storage (no server logout needed)
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
logoutOIDC: async () => {
|
||||
const response = await authApiClient.post('/oidc/logout');
|
||||
return response;
|
||||
},
|
||||
|
||||
loginOIDC: () => {
|
||||
window.location.href = '/auth/oidc/login';
|
||||
},
|
||||
};
|
||||
|
||||
// Bucket API
|
||||
export const bucketsApi = {
|
||||
list: async (): Promise<Bucket[]> => {
|
||||
@@ -139,6 +213,7 @@ export const objectsApi = {
|
||||
size: obj.size,
|
||||
lastModified: obj.last_modified,
|
||||
etag: obj.etag,
|
||||
contentType: obj.content_type,
|
||||
storageClass: obj.storage_class,
|
||||
isFolder: false,
|
||||
})) || [];
|
||||
@@ -161,23 +236,38 @@ export const objectsApi = {
|
||||
},
|
||||
|
||||
get: async (bucket: string, key: string): Promise<Blob> => {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`, {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(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;
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/metadata`);
|
||||
const data = response.data.data;
|
||||
return {
|
||||
key: data.key,
|
||||
size: data.size,
|
||||
lastModified: data.last_modified,
|
||||
contentType: data.content_type,
|
||||
etag: data.etag,
|
||||
storageClass: data.storage_class,
|
||||
metadata: data.metadata,
|
||||
};
|
||||
},
|
||||
|
||||
upload: async (bucket: string, key: string, file: File): Promise<void> => {
|
||||
upload: async (bucket: string, key: string, file: File, onProgress?: (progress: number) => void): 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' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -194,7 +284,7 @@ export const objectsApi = {
|
||||
},
|
||||
|
||||
delete: async (bucket: string, key: string): Promise<void> => {
|
||||
await api.delete(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
|
||||
await api.delete(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`);
|
||||
},
|
||||
|
||||
deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise<void> => {
|
||||
@@ -203,7 +293,7 @@ export const objectsApi = {
|
||||
},
|
||||
|
||||
getPresignedUrl: async (bucket: string, key: string, expiresIn: number = 3600): Promise<string> => {
|
||||
const response = await api.post(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}/presign`, {}, {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/presign`, {
|
||||
params: { expires_in: expiresIn }
|
||||
});
|
||||
return response.data.data.url;
|
||||
@@ -222,6 +312,11 @@ export const accessApi = {
|
||||
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 });
|
||||
|
||||
@@ -98,3 +98,18 @@ export function formatRelativeTime(date: Date): string {
|
||||
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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable size
|
||||
*/
|
||||
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', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
@@ -5,18 +5,6 @@ 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', {
|
||||
|
||||
@@ -46,6 +46,7 @@ export function AccessControl() {
|
||||
const [createPermissionWrite, setCreatePermissionWrite] = useState(false);
|
||||
const [createPermissionOwner, setCreatePermissionOwner] = useState(false);
|
||||
const [createGrantPermissions, setCreateGrantPermissions] = useState(false);
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<AccessKey | null>(null);
|
||||
|
||||
// Edit permissions state
|
||||
const [editPermissionsDialogOpen, setEditPermissionsDialogOpen] = useState(false);
|
||||
@@ -57,18 +58,25 @@ export function AccessControl() {
|
||||
const [permissionOwner, setPermissionOwner] = useState(false);
|
||||
|
||||
// Key settings state (activation/expiration)
|
||||
// const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
// const [settingsKey, setSettingsKey] = useState<AccessKey | null>(null);
|
||||
// const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
|
||||
// const [expirationDate, setExpirationDate] = useState<string>('');
|
||||
// const [neverExpires, setNeverExpires] = useState(true);
|
||||
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [settingsKey, setSettingsKey] = useState<AccessKey | null>(null);
|
||||
const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
|
||||
const [expirationDate, setExpirationDate] = useState<string>('');
|
||||
const [neverExpires, setNeverExpires] = useState(true);
|
||||
|
||||
// Secret key dialog state
|
||||
const [secretKeyDialogOpen, setSecretKeyDialogOpen] = useState(false);
|
||||
const [revealedSecretKey, setRevealedSecretKey] = useState<string>('');
|
||||
const [isLoadingSecretKey, setIsLoadingSecretKey] = useState(false);
|
||||
|
||||
// Key details dialog state
|
||||
const [keyDetailsDialogOpen, setKeyDetailsDialogOpen] = useState(false);
|
||||
const [viewingKey, setViewingKey] = useState<AccessKey | null>(null);
|
||||
const [detailsSecretKey, setDetailsSecretKey] = useState<string>('');
|
||||
const [isLoadingDetailsSecretKey, setIsLoadingDetailsSecretKey] = useState(false);
|
||||
const [copiedAccessKeyId, setCopiedAccessKeyId] = useState(false);
|
||||
const [copiedSecretKey, setCopiedSecretKey] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKeys = async () => {
|
||||
try {
|
||||
@@ -120,13 +128,8 @@ export function AccessControl() {
|
||||
}
|
||||
}
|
||||
|
||||
setCreateDialogOpen(false);
|
||||
setNewKeyName('');
|
||||
setCreateSelectedBucket('');
|
||||
setCreatePermissionRead(false);
|
||||
setCreatePermissionWrite(false);
|
||||
setCreatePermissionOwner(false);
|
||||
setCreateGrantPermissions(false);
|
||||
// Store the newly created key to show the secret key
|
||||
setNewlyCreatedKey(newKey);
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
@@ -138,6 +141,17 @@ export function AccessControl() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseCreateDialog = () => {
|
||||
setCreateDialogOpen(false);
|
||||
setNewKeyName('');
|
||||
setCreateSelectedBucket('');
|
||||
setCreatePermissionRead(false);
|
||||
setCreatePermissionWrite(false);
|
||||
setCreatePermissionOwner(false);
|
||||
setCreateGrantPermissions(false);
|
||||
setNewlyCreatedKey(null);
|
||||
};
|
||||
|
||||
const handleOpenCreateDialog = async () => {
|
||||
setCreateDialogOpen(true);
|
||||
setNewKeyName('');
|
||||
@@ -222,6 +236,23 @@ export function AccessControl() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevealSecretKey = async (key: AccessKey) => {
|
||||
setSelectedKey(key);
|
||||
setIsLoadingSecretKey(true);
|
||||
setSecretKeyDialogOpen(true);
|
||||
setRevealedSecretKey('');
|
||||
|
||||
try {
|
||||
const secretKey = await accessApi.getSecretKey(key.accessKeyId);
|
||||
setRevealedSecretKey(secretKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch secret key:', error);
|
||||
setSecretKeyDialogOpen(false);
|
||||
} finally {
|
||||
setIsLoadingSecretKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditPermissions = async (key: AccessKey) => {
|
||||
setEditingKey(key);
|
||||
setEditPermissionsDialogOpen(true);
|
||||
@@ -312,6 +343,25 @@ export function AccessControl() {
|
||||
return perms.join(', ') || 'None';
|
||||
};
|
||||
|
||||
const handleRowClick = async (key: AccessKey) => {
|
||||
setViewingKey(key);
|
||||
setKeyDetailsDialogOpen(true);
|
||||
setDetailsSecretKey('');
|
||||
setIsLoadingDetailsSecretKey(true);
|
||||
setCopiedAccessKeyId(false);
|
||||
setCopiedSecretKey(false);
|
||||
|
||||
// Fetch the secret key immediately
|
||||
try {
|
||||
const secretKey = await accessApi.getSecretKey(key.accessKeyId);
|
||||
setDetailsSecretKey(secretKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch secret key:', error);
|
||||
} finally {
|
||||
setIsLoadingDetailsSecretKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header
|
||||
@@ -389,7 +439,6 @@ export function AccessControl() {
|
||||
<TableHead className="hidden sm:table-cell">Access Key ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Created</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Last Used</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Permissions</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
@@ -397,7 +446,7 @@ export function AccessControl() {
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<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 API keys...</span>
|
||||
@@ -406,24 +455,36 @@ export function AccessControl() {
|
||||
</TableRow>
|
||||
) : filteredKeys.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery ? 'No keys found matching your search' : 'No API keys yet'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredKeys.map((key) => (
|
||||
<TableRow key={key.accessKeyId}>
|
||||
<TableRow
|
||||
key={key.accessKeyId}
|
||||
onClick={() => handleRowClick(key)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="font-medium truncate max-w-[150px]">{key.name}</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[150px] block">
|
||||
<code
|
||||
className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[150px] block cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(key.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
>
|
||||
{key.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(key.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
@@ -438,9 +499,6 @@ export function AccessControl() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{formatDate(key.createdAt)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{key.lastUsed ? formatDate(key.lastUsed) : 'Never'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.permissions.slice(0, 2).map((perm, idx) => (
|
||||
@@ -458,7 +516,7 @@ export function AccessControl() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon">
|
||||
@@ -466,6 +524,11 @@ export function AccessControl() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleRevealSecretKey(key)}>
|
||||
<Key className="h-4 w-4" />
|
||||
View Secret Key
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleOpenEditPermissions(key)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit Permissions
|
||||
@@ -525,122 +588,214 @@ export function AccessControl() {
|
||||
</div>
|
||||
|
||||
{/* Create Key Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<Dialog open={createDialogOpen} onOpenChange={handleCloseCreateDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new API key with optional bucket permissions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<Input
|
||||
placeholder="My Application Key"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A friendly name to identify this API key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Optional: Grant permissions during creation */}
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="grant-permissions-on-create"
|
||||
checked={createGrantPermissions}
|
||||
onCheckedChange={(checked) => {
|
||||
setCreateGrantPermissions(checked as boolean);
|
||||
if (!checked) {
|
||||
setCreateSelectedBucket('');
|
||||
setCreatePermissionRead(false);
|
||||
setCreatePermissionWrite(false);
|
||||
setCreatePermissionOwner(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium">Grant bucket permissions now</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can also grant permissions later from the Edit Permissions menu
|
||||
</p>
|
||||
|
||||
{createGrantPermissions && (
|
||||
<div className="space-y-4 pl-6 pt-2">
|
||||
{/* Bucket Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Select Bucket</label>
|
||||
<Select
|
||||
value={createSelectedBucket}
|
||||
onChange={(value) => setCreateSelectedBucket(value)}
|
||||
{newlyCreatedKey ? (
|
||||
// Success state - show the secret key
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key Created Successfully</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save your secret access key now. You won't be able to see it again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<div className="text-sm text-muted-foreground">{newlyCreatedKey.name}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Access Key ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(newlyCreatedKey.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
>
|
||||
<SelectOption value="">-- Select a bucket --</SelectOption>
|
||||
{createAvailableBuckets.map((bucket) => (
|
||||
<SelectOption key={bucket.name} value={bucket.name}>
|
||||
{bucket.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
{newlyCreatedKey.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(newlyCreatedKey.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Secret Access Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (newlyCreatedKey.secretKey) {
|
||||
navigator.clipboard.writeText(newlyCreatedKey.secretKey);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{newlyCreatedKey.secretKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (newlyCreatedKey.secretKey) {
|
||||
navigator.clipboard.writeText(newlyCreatedKey.secretKey);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 bg-orange-100 border-orange-300 dark:bg-orange-950/20 dark:border-orange-900">
|
||||
<div className="flex gap-2">
|
||||
<ShieldX className="h-5 w-5 text-orange-700 dark:text-orange-500 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-orange-950 dark:text-orange-200">
|
||||
Important: Save This Key Now
|
||||
</p>
|
||||
<p className="text-xs text-orange-900 dark:text-orange-300">
|
||||
This is the only time you'll see the secret access key. Make sure to copy and save it securely.
|
||||
If you lose it, you'll need to create a new key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleCloseCreateDialog}>
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
// Creation form
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new API key with optional bucket permissions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<Input
|
||||
placeholder="My Application Key"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A friendly name to identify this API key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
{createSelectedBucket && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Permissions</label>
|
||||
<div className="space-y-2 border rounded-lg p-3">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-read"
|
||||
checked={createPermissionRead}
|
||||
onCheckedChange={(checked) => setCreatePermissionRead(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Read</span>
|
||||
<p className="text-xs text-muted-foreground">GetObject, HeadObject, ListObjects</p>
|
||||
</div>
|
||||
</label>
|
||||
{/* Optional: Grant permissions during creation */}
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="grant-permissions-on-create"
|
||||
checked={createGrantPermissions}
|
||||
onCheckedChange={(checked) => {
|
||||
setCreateGrantPermissions(checked as boolean);
|
||||
if (!checked) {
|
||||
setCreateSelectedBucket('');
|
||||
setCreatePermissionRead(false);
|
||||
setCreatePermissionWrite(false);
|
||||
setCreatePermissionOwner(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium">Grant bucket permissions now</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can also grant permissions later from the Edit Permissions menu
|
||||
</p>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-write"
|
||||
checked={createPermissionWrite}
|
||||
onCheckedChange={(checked) => setCreatePermissionWrite(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Write</span>
|
||||
<p className="text-xs text-muted-foreground">PutObject, DeleteObject</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-owner"
|
||||
checked={createPermissionOwner}
|
||||
onCheckedChange={(checked) => setCreatePermissionOwner(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Owner</span>
|
||||
<p className="text-xs text-muted-foreground">DeleteBucket, PutBucketPolicy</p>
|
||||
</div>
|
||||
</label>
|
||||
{createGrantPermissions && (
|
||||
<div className="space-y-4 pl-6 pt-2">
|
||||
{/* Bucket Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Select Bucket</label>
|
||||
<Select
|
||||
value={createSelectedBucket}
|
||||
onChange={(value) => setCreateSelectedBucket(value)}
|
||||
>
|
||||
<SelectOption value="">-- Select a bucket --</SelectOption>
|
||||
{createAvailableBuckets.map((bucket) => (
|
||||
<SelectOption key={bucket.name} value={bucket.name}>
|
||||
{bucket.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
{createSelectedBucket && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Permissions</label>
|
||||
<div className="space-y-2 border rounded-lg p-3">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-read"
|
||||
checked={createPermissionRead}
|
||||
onCheckedChange={(checked) => setCreatePermissionRead(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Read</span>
|
||||
<p className="text-xs text-muted-foreground">GetObject, HeadObject, ListObjects</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-write"
|
||||
checked={createPermissionWrite}
|
||||
onCheckedChange={(checked) => setCreatePermissionWrite(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Write</span>
|
||||
<p className="text-xs text-muted-foreground">PutObject, DeleteObject</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-owner"
|
||||
checked={createPermissionOwner}
|
||||
onCheckedChange={(checked) => setCreatePermissionOwner(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Owner</span>
|
||||
<p className="text-xs text-muted-foreground">DeleteBucket, PutBucketPolicy</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateKey} disabled={!newKeyName}>
|
||||
Create Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCloseCreateDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateKey} disabled={!newKeyName}>
|
||||
Create Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -665,6 +820,95 @@ export function AccessControl() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Secret Key Dialog */}
|
||||
<Dialog open={secretKeyDialogOpen} onOpenChange={setSecretKeyDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Secret Access Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy your secret access key now. For security reasons, it cannot be viewed again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<div className="text-sm text-muted-foreground">{selectedKey?.name}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Access Key ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (selectedKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(selectedKey.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedKey?.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (selectedKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(selectedKey.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Secret Access Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoadingSecretKey ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground flex-1 bg-muted px-3 py-2 rounded">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Loading secret key...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (revealedSecretKey) {
|
||||
navigator.clipboard.writeText(revealedSecretKey);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{revealedSecretKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (revealedSecretKey) {
|
||||
navigator.clipboard.writeText(revealedSecretKey);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
disabled={!revealedSecretKey}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setSecretKeyDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Key Settings Dialog */}
|
||||
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
@@ -736,26 +980,6 @@ export function AccessControl() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Status Display */}
|
||||
<div className="border rounded-lg p-4 bg-muted/50">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Current Status:</span>
|
||||
<Badge variant={settingsKey?.status === 'active' ? 'default' : 'secondary'}>
|
||||
{settingsKey?.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{settingsKey?.expiration && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Current Expiration:</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDate(settingsKey.expiration)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSettingsDialogOpen(false)}>
|
||||
@@ -768,6 +992,190 @@ export function AccessControl() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Key Details Dialog */}
|
||||
<Dialog open={keyDetailsDialogOpen} onOpenChange={setKeyDetailsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
View and manage your API key credentials and permissions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Key Name and Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<div className="text-sm text-muted-foreground">{viewingKey?.name}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<div>
|
||||
<Badge variant={viewingKey?.status === 'active' ? 'default' : 'secondary'}>
|
||||
{viewingKey?.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Key ID */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Access Key ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (viewingKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(viewingKey.accessKeyId);
|
||||
setCopiedAccessKeyId(true);
|
||||
setTimeout(() => setCopiedAccessKeyId(false), 2000);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{viewingKey?.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (viewingKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(viewingKey.accessKeyId);
|
||||
setCopiedAccessKeyId(true);
|
||||
setTimeout(() => setCopiedAccessKeyId(false), 2000);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copiedAccessKeyId ? 'Copied' : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secret Access Key */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Secret Access Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoadingDetailsSecretKey ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground flex-1 bg-muted px-3 py-2 rounded">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Loading secret key...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (detailsSecretKey) {
|
||||
navigator.clipboard.writeText(detailsSecretKey);
|
||||
setCopiedSecretKey(true);
|
||||
setTimeout(() => setCopiedSecretKey(false), 2000);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{'•'.repeat(40)}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (detailsSecretKey) {
|
||||
navigator.clipboard.writeText(detailsSecretKey);
|
||||
setCopiedSecretKey(true);
|
||||
setTimeout(() => setCopiedSecretKey(false), 2000);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
disabled={!detailsSecretKey}
|
||||
>
|
||||
{copiedSecretKey ? 'Copied' : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Created</label>
|
||||
<div className="text-sm text-muted-foreground">{viewingKey && formatDate(viewingKey.createdAt)}</div>
|
||||
</div>
|
||||
{viewingKey?.expiration && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Expiration</label>
|
||||
<div className="text-sm text-muted-foreground">{formatDate(viewingKey.expiration)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bucket Permissions */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Bucket Permissions</label>
|
||||
{viewingKey && viewingKey.permissions.length > 0 ? (
|
||||
<div className="border rounded-lg divide-y">
|
||||
{viewingKey.permissions.map((perm, idx) => (
|
||||
<div key={idx} className="p-3 flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{perm.bucketName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatPermissions(perm)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{perm.read && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Read
|
||||
</Badge>
|
||||
)}
|
||||
{perm.write && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Write
|
||||
</Badge>
|
||||
)}
|
||||
{perm.owner && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Owner
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This key has no bucket permissions yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setKeyDetailsDialogOpen(false);
|
||||
if (viewingKey) {
|
||||
handleOpenEditPermissions(viewingKey);
|
||||
}
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit Permissions
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setKeyDetailsDialogOpen(false)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Permissions Dialog */}
|
||||
<Dialog open={editPermissionsDialogOpen} onOpenChange={setEditPermissionsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { useBuckets } from '@/hooks/useBuckets';
|
||||
import { useBuckets, useCreateBucket, useDeleteBucket, useGrantBucketPermission } from '@/hooks/useApi';
|
||||
import { useBucketObjects } from '@/hooks/useBucketObjects';
|
||||
import { BucketListView } from '@/components/buckets/BucketListView';
|
||||
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
|
||||
@@ -51,7 +51,10 @@ export function Buckets() {
|
||||
}, [searchParams]);
|
||||
|
||||
// Custom hooks
|
||||
const { buckets, isLoading: bucketsLoading, createBucket, deleteBucket, grantPermission } = useBuckets();
|
||||
const { data: buckets = [], isLoading: bucketsLoading } = useBuckets();
|
||||
const createBucketMutation = useCreateBucket();
|
||||
const deleteBucketMutation = useDeleteBucket();
|
||||
const grantPermissionMutation = useGrantBucketPermission();
|
||||
const {
|
||||
objects,
|
||||
isLoading: objectsLoading,
|
||||
@@ -62,6 +65,7 @@ export function Buckets() {
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
uploadFiles,
|
||||
uploadTasks,
|
||||
deleteObject,
|
||||
deleteMultipleObjects,
|
||||
createDirectory,
|
||||
@@ -145,6 +149,38 @@ export function Buckets() {
|
||||
}
|
||||
};
|
||||
|
||||
// Wrapper functions for mutations to match dialog APIs
|
||||
const createBucket = async (name: string, region?: string) => {
|
||||
try {
|
||||
await createBucketMutation.mutateAsync({ name, region });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBucket = async (name: string) => {
|
||||
try {
|
||||
await deleteBucketMutation.mutateAsync(name);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const grantPermission = async (
|
||||
bucketName: string,
|
||||
accessKeyId: string,
|
||||
permissions: { read: boolean; write: boolean; owner: boolean }
|
||||
) => {
|
||||
try {
|
||||
await grantPermissionMutation.mutateAsync({ bucketName, accessKeyId, permissions });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// If viewing a bucket's objects, show the object browser view
|
||||
if (viewingBucket) {
|
||||
return (
|
||||
@@ -161,6 +197,7 @@ export function Buckets() {
|
||||
onNavigateToFolder={handleNavigateToFolder}
|
||||
onBackToBuckets={handleBackToBuckets}
|
||||
onUploadFiles={uploadFiles}
|
||||
uploadTasks={uploadTasks}
|
||||
onDeleteObject={deleteObject}
|
||||
onDeleteMultipleObjects={deleteMultipleObjects}
|
||||
onCreateDirectory={createDirectory}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {formatBytes} from '@/lib/file-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';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {formatBytes} from '@/lib/file-utils';
|
||||
import {AlertCircle, Database, FolderOpen, HardDrive, Server, Zap} from 'lucide-react';
|
||||
import {BucketUsageChart} from '@/components/charts/BucketUsageChart';
|
||||
import {useDashboardData} from '@/hooks/useApi';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { BasicLoginForm } from '@/components/auth/BasicLoginForm';
|
||||
import { OIDCLoginView } from '@/components/auth/OIDCLoginView';
|
||||
import { LoadingSpinner } from '@/components/auth/LoadingSpinner';
|
||||
|
||||
export function Login() {
|
||||
const { config, isLoading, initialize, isAuthenticated } = useAuthStore();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const loginSuccess = searchParams.get('login');
|
||||
const returnUrl = searchParams.get('returnUrl') || '/';
|
||||
|
||||
useEffect(() => {
|
||||
// Handle OIDC callback
|
||||
if (loginSuccess === 'success') {
|
||||
// OIDC login successful, re-initialize auth to fetch user
|
||||
initialize().then(() => {
|
||||
navigate(decodeURIComponent(returnUrl));
|
||||
});
|
||||
}
|
||||
}, [loginSuccess, initialize, navigate, returnUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
// If already authenticated, redirect to return URL
|
||||
if (isAuthenticated && !loginSuccess) {
|
||||
navigate(decodeURIComponent(returnUrl));
|
||||
}
|
||||
}, [isAuthenticated, navigate, returnUrl, loginSuccess]);
|
||||
|
||||
if (isLoading || loginSuccess === 'success') {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
// No auth enabled, redirect to dashboard immediately
|
||||
if (config && !config.admin.enabled && !config.oidc.enabled) {
|
||||
navigate('/');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show login options based on what's enabled
|
||||
const showAdmin = config?.admin.enabled || false;
|
||||
const showOIDC = config?.oidc.enabled || false;
|
||||
|
||||
// If both are enabled, show both options in single modal
|
||||
if (showAdmin && showOIDC) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<BasicLoginForm showOIDC={true} config={config} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show only OIDC if enabled
|
||||
if (showOIDC) {
|
||||
return <OIDCLoginView />;
|
||||
}
|
||||
|
||||
// Show only admin if enabled
|
||||
if (showAdmin) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<BasicLoginForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Still loading config
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { AuthConfig, AuthUser, AuthState } from '@/types/auth';
|
||||
import { authApi } from '@/lib/api';
|
||||
|
||||
interface AuthStore extends AuthState {
|
||||
config: AuthConfig | null;
|
||||
|
||||
// Actions
|
||||
setUser: (user: AuthUser | null) => void;
|
||||
setConfig: (config: AuthConfig) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setAuthenticated: (authenticated: boolean) => void;
|
||||
|
||||
// Async actions
|
||||
initialize: () => Promise<void>;
|
||||
loginAdmin: (username: string, password: string) => Promise<void>;
|
||||
loginOIDC: () => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
config: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
|
||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||
setConfig: (config) => set({ config }),
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
setError: (error) => set({ error }),
|
||||
setAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
|
||||
|
||||
initialize: async () => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
// Fetch auth configuration
|
||||
const configResponse = await authApi.getConfig();
|
||||
const config = configResponse.data as AuthConfig;
|
||||
set({ config });
|
||||
|
||||
// If no auth is enabled, mark as authenticated immediately
|
||||
if (!config.admin.enabled && !config.oidc.enabled) {
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: { username: 'guest' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get current user (check if already authenticated)
|
||||
try {
|
||||
const userResponse = await authApi.me();
|
||||
const user = userResponse.data.user;
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
// Not authenticated - this is okay
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
set({
|
||||
error: 'Failed to initialize authentication',
|
||||
isLoading: false,
|
||||
isAuthenticated: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loginAdmin: async (username, password) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
const response = await authApi.loginAdmin(username, password);
|
||||
const { token, user } = response.data;
|
||||
|
||||
// Store token in localStorage
|
||||
localStorage.setItem('auth-token', token);
|
||||
|
||||
// Update state
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.error?.message || 'Login failed';
|
||||
set({
|
||||
error: errorMessage,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
user: null
|
||||
});
|
||||
throw error; // Re-throw for form handling
|
||||
}
|
||||
},
|
||||
|
||||
loginOIDC: () => {
|
||||
// Redirect to OIDC login endpoint
|
||||
window.location.href = '/auth/oidc/login';
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const { config } = get();
|
||||
|
||||
try {
|
||||
// Call logout endpoint for OIDC mode
|
||||
if (config?.oidc.enabled) {
|
||||
await authApi.logoutOIDC();
|
||||
} else if (config?.admin.enabled) {
|
||||
await authApi.logoutAdmin();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout API call failed:', error);
|
||||
}
|
||||
|
||||
// Clear local storage
|
||||
localStorage.removeItem('auth-token');
|
||||
|
||||
// Clear state
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
// Redirect to login page
|
||||
window.location.href = '/login';
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
// Don't persist config, isLoading, or error
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface AuthConfig {
|
||||
admin: {
|
||||
enabled: boolean;
|
||||
};
|
||||
oidc: {
|
||||
enabled: boolean;
|
||||
provider?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export interface S3Object {
|
||||
size: number;
|
||||
lastModified: string;
|
||||
etag?: string;
|
||||
contentType?: string;
|
||||
storageClass?: string;
|
||||
isFolder?: boolean;
|
||||
}
|
||||
@@ -52,6 +53,7 @@ export interface ObjectMetadata {
|
||||
lastModified: string;
|
||||
contentType: string;
|
||||
etag: string;
|
||||
storageClass?: string;
|
||||
metadata?: Record<string, string>;
|
||||
versionId?: string;
|
||||
}
|
||||
@@ -60,8 +62,8 @@ export interface ObjectMetadata {
|
||||
export interface AccessKey {
|
||||
accessKeyId: string;
|
||||
name: string;
|
||||
secretKey?: string;
|
||||
createdAt: string;
|
||||
lastUsed?: string;
|
||||
status: 'active' | 'inactive';
|
||||
permissions: BucketPermission[];
|
||||
expiration?: string;
|
||||
|
||||
@@ -47,6 +47,15 @@ export default {
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
shimmer: {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
shimmer: 'shimmer 2s infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
@@ -18,6 +18,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/auth': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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/
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: v2
|
||||
name: garage-ui
|
||||
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
|
||||
icon: https://helm.noste.dev/garage.png
|
||||
type: application
|
||||
version: 0.1.7
|
||||
appVersion: "v0.0.11"
|
||||
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"
|
||||
@@ -0,0 +1,887 @@
|
||||
# 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.
|
||||
|
||||
[](Chart.yaml)
|
||||
[](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 Configuration
|
||||
|
||||
**No Authentication** (default, suitable for private networks):
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
admin:
|
||||
enabled: false
|
||||
oidc:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
**Admin Authentication** (username/password with JWT):
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
admin:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password: "your-secure-password"
|
||||
oidc:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
**OIDC/SSO** (recommended for production):
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
admin:
|
||||
enabled: false
|
||||
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
|
||||
```
|
||||
|
||||
**Both Authentication Methods** (admin and OIDC simultaneously):
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
admin:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password: "your-secure-password"
|
||||
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 Admin Authentication
|
||||
|
||||
```yaml
|
||||
# values-admin-auth.yaml
|
||||
config:
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
admin_endpoint: "http://garage:3903"
|
||||
admin_token: "your-admin-token"
|
||||
|
||||
auth:
|
||||
admin:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password: "super-secret-password-change-me"
|
||||
oidc:
|
||||
enabled: false
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: garage.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
```
|
||||
|
||||
Install:
|
||||
```bash
|
||||
helm install garage-ui ./helm/garage-ui -f values-admin-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:
|
||||
admin:
|
||||
enabled: false
|
||||
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.oidc.enabled: true`
|
||||
|
||||
### 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
|
||||
|
||||
**Admin Auth:**
|
||||
- Verify username/password in `config.auth.admin`
|
||||
- Ensure `config.auth.admin.enabled` is set to `true`
|
||||
- Check browser developer tools for 401 errors
|
||||
- Verify JWT token is being sent in Authorization header
|
||||
|
||||
**OIDC:**
|
||||
- Ensure `config.auth.oidc.enabled` is set to `true`
|
||||
- Verify all OIDC URLs are accessible
|
||||
- Check OIDC provider logs
|
||||
- Ensure redirect URI is registered: `https://your-domain/auth/oidc/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.
|
||||
@@ -0,0 +1,27 @@
|
||||
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
|
||||
@@ -0,0 +1,51 @@
|
||||
{{/*
|
||||
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 }}
|
||||
@@ -0,0 +1,15 @@
|
||||
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" }}
|
||||
{{- $_2 := unset $config.auth.admin "password" }}
|
||||
{{- $_3 := unset $config.auth.oidc "client_secret" }}
|
||||
{{- $_4 := unset $config.auth "jwt_private_key" }}
|
||||
{{- $_5 := unset $config.auth "jwt_private_key_secret" }}
|
||||
{{- $config | toYaml | nindent 4 }}
|
||||
@@ -0,0 +1,127 @@
|
||||
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: {{ .Values.config.server.port }}
|
||||
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 or .Values.config.auth.jwt_private_key .Values.config.auth.jwt_private_key_secret.name }}
|
||||
- name: GARAGE_UI_AUTH_JWT_PRIVATE_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
{{- if .Values.config.auth.jwt_private_key_secret.name }}
|
||||
name: {{ .Values.config.auth.jwt_private_key_secret.name }}
|
||||
key: {{ .Values.config.auth.jwt_private_key_secret.key }}
|
||||
{{- else }}
|
||||
name: {{ include "garage-ui.fullname" . }}-jwt-key
|
||||
key: jwt-key
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.config.auth.oidc.enabled }}
|
||||
- name: GARAGE_UI_AUTH_OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
{{- if .Values.config.auth.oidc.existingSecret.name }}
|
||||
name: {{ .Values.config.auth.oidc.existingSecret.name }}
|
||||
key: {{ .Values.config.auth.oidc.existingSecret.key }}
|
||||
{{- else }}
|
||||
name: {{ include "garage-ui.fullname" . }}-oidc-client-secret
|
||||
key: client-secret
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.config.auth.admin.enabled }}
|
||||
- name: GARAGE_UI_AUTH_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
{{- if .Values.config.auth.admin.existingSecret.name }}
|
||||
name: {{ .Values.config.auth.admin.existingSecret.name }}
|
||||
key: {{ .Values.config.auth.admin.existingSecret.key }}
|
||||
{{- else }}
|
||||
name: {{ include "garage-ui.fullname" . }}-admin-password
|
||||
key: admin-password
|
||||
{{- end }}
|
||||
{{- 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 }}
|
||||
@@ -0,0 +1,41 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "garage-ui.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{- if .Values.networkPolicy.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 6 }}
|
||||
policyTypes:
|
||||
{{- toYaml .Values.networkPolicy.policyTypes | nindent 4 }}
|
||||
ingress:
|
||||
- from: []
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
egress:
|
||||
- to: []
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
{{- end }}
|
||||
@@ -0,0 +1,51 @@
|
||||
{{- if and (not .Values.config.garage.existingSecret.name) .Values.config.garage.admin_token }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-admin-token
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
admin-token: {{ .Values.config.garage.admin_token | b64enc | quote }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and .Values.config.auth.admin.enabled (not .Values.config.auth.admin.existingSecret.name) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-admin-password
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{- if .Values.config.auth.admin.password }}
|
||||
admin-password: {{ .Values.config.auth.admin.password | b64enc | quote }}
|
||||
{{- else }}
|
||||
admin-password: {{ randAlphaNum 32 | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and .Values.config.auth.oidc.enabled (not .Values.config.auth.oidc.existingSecret.name) .Values.config.auth.oidc.client_secret }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-oidc-client-secret
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
client-secret: {{ .Values.config.auth.oidc.client_secret | b64enc | quote }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and (not .Values.config.auth.jwt_private_key_secret.name) .Values.config.auth.jwt_private_key }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-jwt-key
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
jwt-key: {{ .Values.config.auth.jwt_private_key | b64enc | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 4 }}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{- if .Values.serviceMonitor.enabled -}}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceMonitor.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 6 }}
|
||||
endpoints:
|
||||
- port: http
|
||||
path: {{ .Values.serviceMonitor.path }}
|
||||
interval: {{ .Values.serviceMonitor.interval }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,854 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Garage UI Helm Chart Values",
|
||||
"description": "Configuration values for the Garage UI Helm chart deployment",
|
||||
"type": "object",
|
||||
"required": ["replicaCount", "image", "config"],
|
||||
"properties": {
|
||||
"replicaCount": {
|
||||
"type": "integer",
|
||||
"description": "Number of replica pods to run. Increase for high availability (recommended: 2-3 for production)",
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
},
|
||||
"image": {
|
||||
"type": "object",
|
||||
"description": "Docker image configuration",
|
||||
"required": ["repository", "pullPolicy"],
|
||||
"properties": {
|
||||
"repository": {
|
||||
"type": "string",
|
||||
"description": "Container registry and image name",
|
||||
"default": "noooste/garage-ui"
|
||||
},
|
||||
"pullPolicy": {
|
||||
"type": "string",
|
||||
"description": "Image pull policy",
|
||||
"enum": ["Always", "IfNotPresent", "Never"],
|
||||
"default": "IfNotPresent"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "Image tag to use (defaults to chart appVersion if empty)",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"imagePullSecrets": {
|
||||
"type": "array",
|
||||
"description": "Credentials for accessing private container registries",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"nameOverride": {
|
||||
"type": "string",
|
||||
"description": "Override the default chart name in resource names",
|
||||
"default": ""
|
||||
},
|
||||
"fullnameOverride": {
|
||||
"type": "string",
|
||||
"description": "Override the full resource name (includes release name)",
|
||||
"default": ""
|
||||
},
|
||||
"config": {
|
||||
"type": "object",
|
||||
"description": "Main application configuration",
|
||||
"required": ["server", "garage", "auth", "cors", "logging"],
|
||||
"properties": {
|
||||
"server": {
|
||||
"type": "object",
|
||||
"description": "Server configuration",
|
||||
"required": ["host", "port", "environment", "protocol"],
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Network interface to bind to (0.0.0.0 for all interfaces)",
|
||||
"default": "0.0.0.0"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"description": "Port the application listens on",
|
||||
"minimum": 1,
|
||||
"maximum": 65535,
|
||||
"default": 8080
|
||||
},
|
||||
"environment": {
|
||||
"type": "string",
|
||||
"description": "Deployment environment",
|
||||
"enum": ["production", "development", "staging"],
|
||||
"default": "production"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "Domain name for the application",
|
||||
"default": "garage-ui.example.com"
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"description": "Protocol for internal communication",
|
||||
"enum": ["http", "https"],
|
||||
"default": "http"
|
||||
},
|
||||
"root_url": {
|
||||
"type": "string",
|
||||
"description": "Full external URL for OAuth2 redirects (REQUIRED when OIDC is enabled)",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://garage-ui.example.com"
|
||||
},
|
||||
"max_body_size": {
|
||||
"type": "integer",
|
||||
"description": "Maximum request body size in bytes (for file uploads)",
|
||||
"minimum": 1,
|
||||
"default": 314572800
|
||||
},
|
||||
"max_header_size": {
|
||||
"type": "integer",
|
||||
"description": "Maximum request header size in bytes",
|
||||
"minimum": 1,
|
||||
"default": 1048576
|
||||
},
|
||||
"read_buffer_size": {
|
||||
"type": "integer",
|
||||
"description": "Read buffer size for request data in bytes",
|
||||
"minimum": 1,
|
||||
"default": 4096
|
||||
},
|
||||
"write_buffer_size": {
|
||||
"type": "integer",
|
||||
"description": "Write buffer size for response data in bytes",
|
||||
"minimum": 1,
|
||||
"default": 4096
|
||||
}
|
||||
}
|
||||
},
|
||||
"garage": {
|
||||
"type": "object",
|
||||
"description": "Garage S3 storage configuration",
|
||||
"required": ["endpoint", "region", "admin_endpoint"],
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"description": "Garage S3 API endpoint",
|
||||
"pattern": "^(https?://)?[a-zA-Z0-9.-]+(:[0-9]+)?$",
|
||||
"default": "http://garage:3900"
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "S3 region name (can be any value for Garage)",
|
||||
"default": "garage"
|
||||
},
|
||||
"admin_endpoint": {
|
||||
"type": "string",
|
||||
"description": "Garage Admin API endpoint",
|
||||
"pattern": "^https?://",
|
||||
"default": "http://garage:3903"
|
||||
},
|
||||
"admin_token": {
|
||||
"type": "string",
|
||||
"description": "Admin API bearer token (ignored if existingSecret is configured)",
|
||||
"default": ""
|
||||
},
|
||||
"existingSecret": {
|
||||
"type": "object",
|
||||
"description": "Use an existing Kubernetes secret for the admin token",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the existing secret containing the admin token",
|
||||
"default": ""
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Key within the secret that contains the admin token value",
|
||||
"default": "admin-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"description": "Authentication configuration (one or both methods can be enabled)",
|
||||
"required": ["admin", "oidc"],
|
||||
"properties": {
|
||||
"jwt_private_key": {
|
||||
"type": "string",
|
||||
"description": "Ed25519 private key for JWT signing in PEM format (EdDSA algorithm). Generate with: openssl genpkey -algorithm ED25519. If not provided, auto-generated on each restart (not recommended for production)",
|
||||
"default": ""
|
||||
},
|
||||
"jwt_private_key_secret": {
|
||||
"type": "object",
|
||||
"description": "Use an existing Kubernetes secret for the JWT private key (recommended)",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the existing secret containing the JWT private key",
|
||||
"default": ""
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Key within the secret that contains the JWT private key value",
|
||||
"default": "jwt-key.pem"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"type": "object",
|
||||
"description": "Admin authentication settings (username/password)",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable admin authentication",
|
||||
"default": false
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Username for admin login",
|
||||
"default": "admin"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Password for admin login (ignored if existingSecret is configured)",
|
||||
"default": "changeme"
|
||||
},
|
||||
"existingSecret": {
|
||||
"type": "object",
|
||||
"description": "Use an existing Kubernetes secret for the admin password",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the existing secret containing the admin password",
|
||||
"default": ""
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Key within the secret that contains the admin password value",
|
||||
"default": "admin-password"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"oidc": {
|
||||
"type": "object",
|
||||
"description": "OpenID Connect (OIDC) configuration",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable OIDC authentication",
|
||||
"default": false
|
||||
},
|
||||
"provider_name": {
|
||||
"type": "string",
|
||||
"description": "Display name of your OIDC provider",
|
||||
"default": "Keycloak"
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string",
|
||||
"description": "OAuth2 client ID registered with your OIDC provider",
|
||||
"default": "garage-ui"
|
||||
},
|
||||
"client_secret": {
|
||||
"type": "string",
|
||||
"description": "OAuth2 client secret (ignored if existingSecret is configured)",
|
||||
"default": "your-client-secret"
|
||||
},
|
||||
"existingSecret": {
|
||||
"type": "object",
|
||||
"description": "Use an existing Kubernetes secret for the client secret",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the existing secret containing the client secret",
|
||||
"default": ""
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Key within the secret that contains the client secret value",
|
||||
"default": "client-secret"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"description": "OAuth2/OIDC scopes to request during authentication",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["openid", "email", "profile"]
|
||||
},
|
||||
"issuer_url": {
|
||||
"type": "string",
|
||||
"description": "OIDC issuer URL (base URL for OIDC discovery)",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://keycloak.example.com/realms/master"
|
||||
},
|
||||
"auth_url": {
|
||||
"type": "string",
|
||||
"description": "Authorization endpoint URL",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
|
||||
},
|
||||
"token_url": {
|
||||
"type": "string",
|
||||
"description": "Token endpoint URL",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
|
||||
},
|
||||
"userinfo_url": {
|
||||
"type": "string",
|
||||
"description": "User info endpoint URL",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
|
||||
},
|
||||
"skip_issuer_check": {
|
||||
"type": "boolean",
|
||||
"description": "Skip issuer validation (not recommended for production)",
|
||||
"default": false
|
||||
},
|
||||
"skip_expiry_check": {
|
||||
"type": "boolean",
|
||||
"description": "Skip token expiry validation (not recommended for production)",
|
||||
"default": false
|
||||
},
|
||||
"email_attribute": {
|
||||
"type": "string",
|
||||
"description": "Claim containing user's email address",
|
||||
"default": "email"
|
||||
},
|
||||
"username_attribute": {
|
||||
"type": "string",
|
||||
"description": "Claim containing user's username/login ID",
|
||||
"default": "preferred_username"
|
||||
},
|
||||
"name_attribute": {
|
||||
"type": "string",
|
||||
"description": "Claim containing user's display name",
|
||||
"default": "name"
|
||||
},
|
||||
"role_attribute_path": {
|
||||
"type": "string",
|
||||
"description": "Path to roles in the OIDC token claims",
|
||||
"default": "resource_access.garage-ui.roles"
|
||||
},
|
||||
"admin_role": {
|
||||
"type": "string",
|
||||
"description": "Role name that grants admin privileges",
|
||||
"default": "admin"
|
||||
},
|
||||
"tls_skip_verify": {
|
||||
"type": "boolean",
|
||||
"description": "Skip TLS certificate verification (only for testing)",
|
||||
"default": false
|
||||
},
|
||||
"session_max_age": {
|
||||
"type": "integer",
|
||||
"description": "Session validity duration in seconds",
|
||||
"minimum": 60,
|
||||
"default": 86400
|
||||
},
|
||||
"cookie_name": {
|
||||
"type": "string",
|
||||
"description": "Name of the session cookie",
|
||||
"default": "garage_session"
|
||||
},
|
||||
"cookie_secure": {
|
||||
"type": "boolean",
|
||||
"description": "Only send cookie over HTTPS connections",
|
||||
"default": true
|
||||
},
|
||||
"cookie_http_only": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent JavaScript access to the cookie",
|
||||
"default": true
|
||||
},
|
||||
"cookie_same_site": {
|
||||
"type": "string",
|
||||
"description": "SameSite cookie attribute for CSRF protection",
|
||||
"enum": ["lax", "strict", "none"],
|
||||
"default": "lax"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cors": {
|
||||
"type": "object",
|
||||
"description": "CORS (Cross-Origin Resource Sharing) configuration",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable CORS",
|
||||
"default": true
|
||||
},
|
||||
"allowed_origins": {
|
||||
"type": "array",
|
||||
"description": "List of allowed origins",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["*"]
|
||||
},
|
||||
"allowed_methods": {
|
||||
"type": "array",
|
||||
"description": "HTTP methods allowed in CORS requests",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||
},
|
||||
"default": ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
},
|
||||
"allowed_headers": {
|
||||
"type": "array",
|
||||
"description": "HTTP headers allowed in CORS requests",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["Origin", "Content-Type", "Accept", "Authorization"]
|
||||
},
|
||||
"allow_credentials": {
|
||||
"type": "boolean",
|
||||
"description": "Allow credentials in CORS requests (cannot be true when allowed_origins contains '*')",
|
||||
"default": false
|
||||
},
|
||||
"max_age": {
|
||||
"type": "integer",
|
||||
"description": "Cache duration for CORS preflight responses in seconds",
|
||||
"minimum": 0,
|
||||
"default": 3600
|
||||
}
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"type": "object",
|
||||
"description": "Logging configuration",
|
||||
"required": ["level", "format"],
|
||||
"properties": {
|
||||
"level": {
|
||||
"type": "string",
|
||||
"description": "Log verbosity level",
|
||||
"enum": ["debug", "info", "warn", "error"],
|
||||
"default": "info"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"description": "Log output format",
|
||||
"enum": ["json", "text"],
|
||||
"default": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"podAnnotations": {
|
||||
"type": "object",
|
||||
"description": "Annotations to add to the pod",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"podSecurityContext": {
|
||||
"type": "object",
|
||||
"description": "Pod-level security context",
|
||||
"properties": {
|
||||
"runAsNonRoot": {
|
||||
"type": "boolean",
|
||||
"description": "Run containers as non-root user",
|
||||
"default": true
|
||||
},
|
||||
"runAsUser": {
|
||||
"type": "integer",
|
||||
"description": "User ID to run containers as",
|
||||
"minimum": 0,
|
||||
"default": 1000
|
||||
},
|
||||
"fsGroup": {
|
||||
"type": "integer",
|
||||
"description": "Group ID for filesystem access",
|
||||
"minimum": 0,
|
||||
"default": 1000
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityContext": {
|
||||
"type": "object",
|
||||
"description": "Container-level security context",
|
||||
"properties": {
|
||||
"allowPrivilegeEscalation": {
|
||||
"type": "boolean",
|
||||
"description": "Allow privilege escalation",
|
||||
"default": false
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"drop": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["ALL"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"readOnlyRootFilesystem": {
|
||||
"type": "boolean",
|
||||
"description": "Mount root filesystem as read-only",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"service": {
|
||||
"type": "object",
|
||||
"description": "Kubernetes Service configuration",
|
||||
"required": ["type", "port"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Service type",
|
||||
"enum": ["ClusterIP", "NodePort", "LoadBalancer"],
|
||||
"default": "ClusterIP"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"description": "Port the service listens on",
|
||||
"minimum": 1,
|
||||
"maximum": 65535,
|
||||
"default": 80
|
||||
}
|
||||
}
|
||||
},
|
||||
"ingress": {
|
||||
"type": "object",
|
||||
"description": "Ingress configuration",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable ingress",
|
||||
"default": false
|
||||
},
|
||||
"className": {
|
||||
"type": "string",
|
||||
"description": "Ingress class name",
|
||||
"default": "nginx"
|
||||
},
|
||||
"annotations": {
|
||||
"type": "object",
|
||||
"description": "Additional annotations for the ingress",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"description": "Hostname and path configuration",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["host"],
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Hostname for the ingress"
|
||||
},
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["path", "pathType"],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "URL path"
|
||||
},
|
||||
"pathType": {
|
||||
"type": "string",
|
||||
"description": "Path type",
|
||||
"enum": ["Prefix", "Exact", "ImplementationSpecific"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": [
|
||||
{
|
||||
"host": "garage-ui.local",
|
||||
"paths": [
|
||||
{
|
||||
"path": "/",
|
||||
"pathType": "Prefix"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tls": {
|
||||
"type": "array",
|
||||
"description": "TLS/SSL configuration",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"secretName": {
|
||||
"type": "string",
|
||||
"description": "Name of the TLS secret"
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"type": "object",
|
||||
"description": "CPU and memory resource limits and requests",
|
||||
"properties": {
|
||||
"limits": {
|
||||
"type": "object",
|
||||
"description": "Maximum resources the container can use",
|
||||
"properties": {
|
||||
"cpu": {
|
||||
"type": "string",
|
||||
"description": "Maximum CPU cores",
|
||||
"pattern": "^[0-9]+(m|[0-9]*\\.?[0-9]+)?$",
|
||||
"default": "500m"
|
||||
},
|
||||
"memory": {
|
||||
"type": "string",
|
||||
"description": "Maximum memory",
|
||||
"pattern": "^[0-9]+(Ki|Mi|Gi|Ti|Pi|Ei)?$",
|
||||
"default": "512Mi"
|
||||
}
|
||||
}
|
||||
},
|
||||
"requests": {
|
||||
"type": "object",
|
||||
"description": "Guaranteed resources allocated to the container",
|
||||
"properties": {
|
||||
"cpu": {
|
||||
"type": "string",
|
||||
"description": "Guaranteed CPU",
|
||||
"pattern": "^[0-9]+(m|[0-9]*\\.?[0-9]+)?$",
|
||||
"default": "100m"
|
||||
},
|
||||
"memory": {
|
||||
"type": "string",
|
||||
"description": "Guaranteed memory",
|
||||
"pattern": "^[0-9]+(Ki|Mi|Gi|Ti|Pi|Ei)?$",
|
||||
"default": "128Mi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"livenessProbe": {
|
||||
"type": "object",
|
||||
"description": "Liveness probe configuration",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable liveness probe",
|
||||
"default": true
|
||||
},
|
||||
"httpGet": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Health check endpoint path",
|
||||
"default": "/health"
|
||||
},
|
||||
"port": {
|
||||
"type": "string",
|
||||
"description": "Port name",
|
||||
"default": "http"
|
||||
}
|
||||
}
|
||||
},
|
||||
"initialDelaySeconds": {
|
||||
"type": "integer",
|
||||
"description": "Wait time before starting liveness checks",
|
||||
"minimum": 0,
|
||||
"default": 30
|
||||
},
|
||||
"periodSeconds": {
|
||||
"type": "integer",
|
||||
"description": "How often to perform the probe",
|
||||
"minimum": 1,
|
||||
"default": 10
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"type": "integer",
|
||||
"description": "Maximum time to wait for probe completion",
|
||||
"minimum": 1,
|
||||
"default": 3
|
||||
},
|
||||
"failureThreshold": {
|
||||
"type": "integer",
|
||||
"description": "Consecutive failures before restarting",
|
||||
"minimum": 1,
|
||||
"default": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"readinessProbe": {
|
||||
"type": "object",
|
||||
"description": "Readiness probe configuration",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable readiness probe",
|
||||
"default": true
|
||||
},
|
||||
"httpGet": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Readiness check endpoint path",
|
||||
"default": "/health"
|
||||
},
|
||||
"port": {
|
||||
"type": "string",
|
||||
"description": "Port name",
|
||||
"default": "http"
|
||||
}
|
||||
}
|
||||
},
|
||||
"initialDelaySeconds": {
|
||||
"type": "integer",
|
||||
"description": "Wait time before starting readiness checks",
|
||||
"minimum": 0,
|
||||
"default": 10
|
||||
},
|
||||
"periodSeconds": {
|
||||
"type": "integer",
|
||||
"description": "How often to perform the probe",
|
||||
"minimum": 1,
|
||||
"default": 5
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"type": "integer",
|
||||
"description": "Maximum time to wait for probe completion",
|
||||
"minimum": 1,
|
||||
"default": 3
|
||||
},
|
||||
"failureThreshold": {
|
||||
"type": "integer",
|
||||
"description": "Consecutive failures before marking as not ready",
|
||||
"minimum": 1,
|
||||
"default": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"serviceMonitor": {
|
||||
"type": "object",
|
||||
"description": "ServiceMonitor for Prometheus Operator",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable ServiceMonitor creation",
|
||||
"default": false
|
||||
},
|
||||
"interval": {
|
||||
"type": "string",
|
||||
"description": "How often Prometheus should scrape metrics",
|
||||
"pattern": "^[0-9]+(s|m|h)$",
|
||||
"default": "30s"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Metrics endpoint path",
|
||||
"default": "/api/v1/monitoring/metrics"
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"description": "Additional labels for the ServiceMonitor",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"networkPolicy": {
|
||||
"type": "object",
|
||||
"description": "NetworkPolicy configuration",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable NetworkPolicy creation",
|
||||
"default": false
|
||||
},
|
||||
"policyTypes": {
|
||||
"type": "array",
|
||||
"description": "Types of policies to enforce",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["Ingress", "Egress"]
|
||||
},
|
||||
"default": ["Ingress", "Egress"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodeSelector": {
|
||||
"type": "object",
|
||||
"description": "Node selector labels",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"tolerations": {
|
||||
"type": "array",
|
||||
"description": "Tolerations for pod scheduling",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"enum": ["Exists", "Equal"]
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"effect": {
|
||||
"type": "string",
|
||||
"enum": ["NoSchedule", "PreferNoSchedule", "NoExecute"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"affinity": {
|
||||
"type": "object",
|
||||
"description": "Affinity rules for pod scheduling",
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
# Default values for garage-ui
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: noooste/garage-ui
|
||||
pullPolicy: IfNotPresent
|
||||
# Overrides the image tag whose default is the chart appVersion
|
||||
tag: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
config:
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
environment: "production"
|
||||
domain: "garage-ui.example.com"
|
||||
protocol: "http"
|
||||
# Full external URL (required for OIDC)
|
||||
root_url: "https://garage-ui.example.com"
|
||||
# Request size limits (in bytes)
|
||||
max_body_size: 314572800 # 300MB
|
||||
max_header_size: 1048576 # 1MB
|
||||
read_buffer_size: 4096 # 4KB
|
||||
write_buffer_size: 4096 # 4KB
|
||||
|
||||
garage:
|
||||
# Garage S3 API endpoint
|
||||
endpoint: "http://garage:3900"
|
||||
region: "garage"
|
||||
# Garage Admin API endpoint
|
||||
admin_endpoint: "http://garage:3903"
|
||||
# Admin API bearer token
|
||||
admin_token: ""
|
||||
# Use existing secret for admin token (recommended)
|
||||
existingSecret:
|
||||
name: ""
|
||||
key: "admin-token"
|
||||
|
||||
auth:
|
||||
# Ed25519 private key for JWT signing (PEM format)
|
||||
# Generate with: openssl genpkey -algorithm ED25519
|
||||
# If not provided, auto-generated on each restart (not recommended for production)
|
||||
jwt_private_key: ""
|
||||
# Use existing secret for JWT private key (recommended)
|
||||
jwt_private_key_secret:
|
||||
name: ""
|
||||
key: "jwt-key.pem"
|
||||
|
||||
# Admin authentication (username/password)
|
||||
admin:
|
||||
enabled: false
|
||||
username: "admin"
|
||||
password: "changeme"
|
||||
existingSecret:
|
||||
name: ""
|
||||
key: "admin-password"
|
||||
|
||||
# OIDC authentication (Keycloak, Auth0, Okta, etc.)
|
||||
# NOTE: Requires server.root_url to be set
|
||||
oidc:
|
||||
enabled: false
|
||||
provider_name: "Keycloak"
|
||||
client_id: "garage-ui"
|
||||
client_secret: "your-client-secret"
|
||||
existingSecret:
|
||||
name: ""
|
||||
key: "client-secret"
|
||||
scopes:
|
||||
- openid
|
||||
- email
|
||||
- profile
|
||||
# OIDC provider endpoints
|
||||
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"
|
||||
# Validation settings
|
||||
skip_issuer_check: false
|
||||
skip_expiry_check: false
|
||||
# User attribute mappings
|
||||
email_attribute: "email"
|
||||
username_attribute: "preferred_username"
|
||||
name_attribute: "name"
|
||||
# Role-based access control
|
||||
role_attribute_path: "resource_access.garage-ui.roles"
|
||||
admin_role: "admin"
|
||||
# TLS settings
|
||||
tls_skip_verify: false
|
||||
# Session settings
|
||||
session_max_age: 86400 # 24 hours
|
||||
cookie_name: "garage_session"
|
||||
cookie_secure: true
|
||||
cookie_http_only: true
|
||||
cookie_same_site: "lax"
|
||||
|
||||
# CORS configuration
|
||||
cors:
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- "*"
|
||||
allowed_methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
allowed_headers:
|
||||
- Origin
|
||||
- Content-Type
|
||||
- Accept
|
||||
- Authorization
|
||||
allow_credentials: false
|
||||
max_age: 3600
|
||||
|
||||
logging:
|
||||
# Options: debug, info, warn, error
|
||||
level: "info"
|
||||
# Options: json, text
|
||||
format: "json"
|
||||
|
||||
# Pod annotations
|
||||
podAnnotations: {}
|
||||
|
||||
# Pod security context
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
fsGroup: 1000
|
||||
|
||||
# Container security context
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: false
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: "nginx"
|
||||
annotations: {}
|
||||
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
# nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
hosts:
|
||||
- host: garage-ui.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
# - secretName: garage-ui-tls
|
||||
# hosts:
|
||||
# - garage-ui.local
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
|
||||
livenessProbe:
|
||||
enabled: true
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
enabled: true
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
# ServiceMonitor for Prometheus Operator
|
||||
serviceMonitor:
|
||||
enabled: false
|
||||
interval: 30s
|
||||
path: /api/v1/monitoring/metrics
|
||||
labels: {}
|
||||
|
||||
# NetworkPolicy
|
||||
networkPolicy:
|
||||
enabled: false
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
|
||||
# Node labels for pod assignment
|
||||
nodeSelector: {}
|
||||
|
||||
# Tolerations for pod assignment
|
||||
tolerations: []
|
||||
|
||||
# Affinity for pod assignment
|
||||
affinity: {}
|
||||
Reference in New Issue
Block a user