Compare commits

..

41 Commits

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

Before

Width:  |  Height:  |  Size: 4.0 KiB

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

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Garage UI Helm Charts</title>
</head>
<body>
<h1>Garage UI Helm Charts Repository</h1>
<p>This repository hosts Helm charts for Garage UI.</p>
</body>
</html>
+743
View File
@@ -0,0 +1,743 @@
apiVersion: v1
entries:
garage-ui:
- apiVersion: v2
appVersion: v0.10.0
created: "2026-07-14T22:02:25.937552154Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 644e902ab2d2763fd051b4f848d60d340af9d8d585db987bd9c518f13d2a8875
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.10.0/garage-ui-0.10.0.tgz
version: 0.10.0
- apiVersion: v2
appVersion: v0.9.0
created: "2026-07-11T15:59:51.918789223Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 49ffde59ac0e25b5ec252936ba21c16d76871b9faf99c2e77c8a4da571a5c781
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.9.0/garage-ui-0.9.0.tgz
version: 0.9.0
- apiVersion: v2
appVersion: v0.8.5
created: "2026-07-03T09:13:56.409587632Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: e1144409eee6c5dd9be0f2bcd89db5f63d92048427173fae734ff18adc66d7f9
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.8.5/garage-ui-0.8.5.tgz
version: 0.8.5
- apiVersion: v2
appVersion: v0.8.4
created: "2026-06-22T13:33:24.056351219Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 0dff94196735d487442d518efd4edf0e077089882309fbb50aa2b2eb3de9a83f
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.8.4/garage-ui-0.8.4.tgz
version: 0.8.4
- apiVersion: v2
appVersion: v0.8.2
created: "2026-06-22T10:24:16.053811326Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 704c2a4ddcc0f407f9effbcf209726a5ca51f24b3b5d84bdd71d395a82c2a135
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.8.3/garage-ui-0.8.3.tgz
version: 0.8.3
- apiVersion: v2
appVersion: v0.8.0
created: "2026-05-31T10:51:46.555183349Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 40cf19894886bddd680b263bb152b32ac76012f8ab7293de6492530f6f958c08
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.6.1/garage-ui-0.6.1.tgz
version: 0.6.1
- apiVersion: v2
appVersion: v0.8.0
created: "2026-05-31T09:58:01.822743406Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 3f0b2fba5cbd90321bc34bb2ecfffddba8954b7a6d2cefe45381fbce4bd1f2f3
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.6.0/garage-ui-0.6.0.tgz
version: 0.6.0
- apiVersion: v2
appVersion: v0.7.0
created: "2026-05-25T16:52:14.759049574Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 4778486a22888dbd237546bbe3c65d215fbc9ecc97c58193a98877674b7bf614
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.5.1/garage-ui-0.5.1.tgz
version: 0.5.1
- apiVersion: v2
appVersion: v0.6.2
created: "2026-05-16T08:27:52.044589945Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: b18baccfab2ac3f610cc1f64d86d39be06f8c43d36a1f46e3a23da298afbc745
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.5.0/garage-ui-0.5.0.tgz
version: 0.5.0
- apiVersion: v2
appVersion: v0.6.1
created: "2026-05-15T21:30:14.820759799Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 31dd472f593cfb991a4714bffdca07b2e6d6360fe2f748241b9031764bde0875
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.4.2/garage-ui-0.4.2.tgz
version: 0.4.2
- apiVersion: v2
appVersion: v0.4.2
created: "2026-04-19T21:30:05.95322947Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 331710d03e52b2324c2726fb0f8efd01082a76babe7c677e867603010d014f7b
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.2.5/garage-ui-0.2.5.tgz
version: 0.2.5
- apiVersion: v2
appVersion: v0.4.0
created: "2026-04-19T20:51:00.352568609Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 7b3f628acfc32c43788fc85851bc46f597404db179c1d22d76dfd5fc10a70571
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.2.4/garage-ui-0.2.4.tgz
version: 0.2.4
- apiVersion: v2
appVersion: v0.3.0
created: "2026-04-19T15:16:00.570571584Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: bb0b738028923eb185e6f33e4fff633ab885bc97493c130729f7006290d9bfdb
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.2.3/garage-ui-0.2.3.tgz
version: 0.2.3
- apiVersion: v2
appVersion: v0.2.2
created: "2026-04-13T22:07:32.349667532Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 20203619feaa74f90c04c7a8890989ccc08577c106e4c045f94dd3f3a727f5b9
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.2.2/garage-ui-0.2.2.tgz
version: 0.2.2
- apiVersion: v2
appVersion: v0.2.1
created: "2026-04-13T20:24:00.112373567Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 38d55d3786b00665a9807355bef6dfc24794b4ee2ac037c99c4adbfd130b246b
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.2.1/garage-ui-0.2.1.tgz
version: 0.2.1
- apiVersion: v2
appVersion: v0.2.0
created: "2026-03-07T13:30:46.302257188Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: f61167d4b24839603154e2d58e7bc105b0b6322d2d954c3042739bf69c49f266
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.2.0/garage-ui-0.2.0.tgz
version: 0.2.0
- apiVersion: v2
appVersion: v0.1.15
created: "2026-03-07T09:57:05.759657683Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: bb96f0c82b0c00ed5af91c3811e50db5cdcd62642a08d240680397c56aa9cb32
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.15/garage-ui-0.1.15.tgz
version: 0.1.15
- apiVersion: v2
appVersion: v0.1.2
created: "2025-12-26T15:02:29.676184304Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: ace18c56a565023e9c758b94a09082104a2a157dcff8cf56ed90542e9aed1c30
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.13/garage-ui-0.1.13.tgz
version: 0.1.13
- apiVersion: v2
appVersion: v0.1.1
created: "2025-12-26T14:44:53.804083349Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: c5d16ccda602dd7b7601d181c5410fdc899505c86775b095594cb34fc3891eae
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.12/garage-ui-0.1.12.tgz
version: 0.1.12
- apiVersion: v2
appVersion: v0.1.0
created: "2025-12-26T14:38:20.230001238Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 710b943ed63e4321d80984ab9935238d3244dd221cd9c372e9f179222a9b7446
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.11/garage-ui-0.1.11.tgz
version: 0.1.11
- apiVersion: v2
appVersion: v0.1.0
created: "2025-12-26T14:31:47.86369677Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 8b38f8326237ef80389194b811ba5da434ccbff313a050ee9c006b87bab23e23
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.10/garage-ui-0.1.10.tgz
version: 0.1.10
- apiVersion: v2
appVersion: v0.1.0
created: "2025-12-26T14:29:05.648833672Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: b032cddb774a49101c74b42ac54d7f6c9adeda33bc5e03f59caba6b4ee6da43f
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.9/garage-ui-0.1.9.tgz
version: 0.1.9
- apiVersion: v2
appVersion: v0.1.0
created: "2025-12-26T11:30:30.258071134Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 98fa4db63c7126e74d57170f1b60b3adb0f1552aea1d67042cdbc73e9f0af65f
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.8/garage-ui-0.1.8.tgz
version: 0.1.8
- apiVersion: v2
appVersion: v0.0.11
created: "2025-12-22T11:15:58.489931751Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: e248766f4633226bc5a3c94436bf5fbbc2206e5d301dc885699fb73713cd6740
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.7/garage-ui-0.1.7.tgz
version: 0.1.7
- apiVersion: v2
appVersion: v0.0.10
created: "2025-12-21T23:15:49.461880724Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 75c83ae47b2c54ee327cb26514b49e97a655d3a8ce5e19464a694ea5b5f4b1b2
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.6/garage-ui-0.1.6.tgz
version: 0.1.6
- apiVersion: v2
appVersion: v0.0.10
created: "2025-12-21T23:10:42.866164498Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 199b99a6241da7e359b8836f26d283d0bb73b73a4b374f43e1e10b1698d0faba
home: https://github.com/Noooste/garage-ui
icon: https://helm.noste.dev/garage.png
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.5/garage-ui-0.1.5.tgz
version: 0.1.5
- apiVersion: v2
appVersion: v0.0.10
created: "2025-12-21T22:53:32.764267848Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 89d467c19981f074f4f48feb6bd3557cee3325df2f2b4ae6c6195a55a94b9012
home: https://github.com/Noooste/garage-ui
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.4/garage-ui-0.1.4.tgz
version: 0.1.4
- apiVersion: v2
appVersion: v0.0.7
created: "2025-12-21T14:54:21.781976339Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: f0c412cc97fc5ad2d46374dee946692cf3e59b7f9ffcf198eda62bcf665b74d7
home: https://github.com/Noooste/garage-ui
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.3/garage-ui-0.1.3.tgz
version: 0.1.3
- apiVersion: v2
appVersion: v0.0.6
created: "2025-12-21T13:09:15.118382669Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: e76d3aa44747e881ba72c8b97369a2b338a239a71b30a36aeb154d01ee89b1f9
home: https://github.com/Noooste/garage-ui
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.2/garage-ui-0.1.2.tgz
version: 0.1.2
- apiVersion: v2
appVersion: v0.0.6
created: "2025-12-20T12:09:27.712624624Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 4ab0ad800a2858f35da11ef9aaeda506c593dca2ce627f134651291f588a01fe
home: https://github.com/Noooste/garage-ui
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.1/garage-ui-0.1.1.tgz
version: 0.1.1
- apiVersion: v2
appVersion: v0.0.4
created: "2025-12-20T09:20:15.889513885Z"
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
digest: 84b1146a99cd2a6469ca238e41ff2b9e09bc19a4d08c3fa8a17de15582350cdf
home: https://github.com/Noooste/garage-ui
keywords:
- garage
- s3
- object-storage
- ui
- dashboard
- web-ui
kubeVersion: '>=1.19.0-0'
maintainers:
- name: Noooste
name: garage-ui
sources:
- https://github.com/Noooste/garage-ui
type: application
urls:
- https://github.com/Noooste/garage-ui/releases/download/garage-ui-0.1.0/garage-ui-0.1.0.tgz
version: 0.1.0
generated: "2026-07-14T22:02:25.9378764Z"