Compare commits

...

13 Commits

Author SHA1 Message Date
Noooste c951e5fa4b feat: implement permission denial functionality and enhance permission saving state in AccessControl component
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 23:15:27 +02:00
Noooste a828020994 feat: enhance AccessControl component with ConfirmDialog and improved UI for key management
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 23:09:29 +02:00
Noooste dcef1e2cbd chore: update version and appVersion in Chart.yaml and README.md
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 22:50:45 +02:00
Noooste 13e6fa3d1d refactor: remove unused state variables in AccessControl component
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 22:41:21 +02:00
Noooste fece799627 refactor: improve error handling and utility functions in bucket management components
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 22:37:55 +02:00
Noste c5324db1ad Merge pull request #18 from Noooste/feat/ui-redesign
UI redesign
2026-04-19 22:17:57 +02:00
Noooste bfed48421b feat: implement UI redesign with updated button styles and new components
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 22:11:22 +02:00
Noooste 38cc1dded0 docs: add roadmap section to README outlining future features
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 19:00:35 +02:00
Noooste d83313d866 chore: rename workflow names in build.yml and release.yml for consistency
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 18:39:55 +02:00
Noooste 6f10510c49 docs: update Codecov badge text for clarity
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 18:39:10 +02:00
Noooste dd0e0d43fb docs: add Codecov badge to README for coverage tracking
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 18:38:49 +02:00
Noooste 4447f6533f test: add comprehensive tests for admin methods and logger error handling
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 18:37:32 +02:00
Noooste 09289371a2 test: add unit tests for CreateDirectory endpoint and related S3 functionality
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 18:11:21 +02:00
67 changed files with 4291 additions and 2010 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
name: Docker Build and Push
name: build
on:
push:
+1 -1
View File
@@ -1,4 +1,4 @@
name: Release Charts
name: release
on:
push:
+9
View File
@@ -45,6 +45,14 @@ jobs:
name: coverage
path: coverage.out
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: Noooste/garage-ui
files: ./coverage.out
fail_ci_if_error: false
smoke:
name: Smoke test (docker compose)
runs-on: ubuntu-latest
@@ -72,3 +80,4 @@ jobs:
run: |
docker compose -p garage-ui-smoke \
-f backend/tests/smoke/docker-compose.test.yml down -v || true
+4 -1
View File
@@ -63,4 +63,7 @@ garage.toml
backend/docs/
config.yaml
docs/**/*.md
docs/**/*.md
# Superpowers brainstorm / session scratch
.superpowers/
+46 -1
View File
@@ -4,9 +4,9 @@ A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object st
[![Docker Build](https://github.com/Noooste/garage-ui/actions/workflows/build.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
[![Helm Chart](https://github.com/Noooste/garage-ui/actions/workflows/release.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
[![Coverage](https://codecov.io/gh/Noooste/garage-ui/branch/main/graph/badge.svg)](https://codecov.io/gh/Noooste/garage-ui)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go)](https://go.dev/)
[![Node Version](https://img.shields.io/badge/Node-25%2B-339933?logo=node.js)](https://nodejs.org/)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui)](https://artifacthub.io/packages/search?repo=garage-ui)
---
@@ -186,6 +186,51 @@ logging:
format: "text" # or "json"
```
## Roadmap
Ideas being considered. Contributions welcome.
**Object browser**
- [ ] Inline preview (images, PDF, video, text/markdown, code)
- [ ] Resumable multipart uploads with pause/resume
- [ ] Folder uploads preserving prefix structure
- [ ] Bulk actions (delete, copy prefix, download prefix as zip)
- [ ] Command palette (Cmd-K) and keyboard navigation
**Sharing**
- [ ] Presigned download links with expiry + QR code
- [ ] Presigned upload drop-zones ("send me a file" pages)
**Buckets**
- [ ] Bucket alias manager (global vs. user-scoped)
- [ ] Quota editor with live usage bar
- [ ] Lifecycle editor (expiration + abort-multipart)
- [ ] CORS editor with built-in test request
- [ ] Website config (index/error docs) with live link
- [ ] Per-bucket usage graph over time
**Access keys**
- [ ] Permission matrix view (keys × buckets)
- [ ] Key rotation helper
- [ ] Copy-ready snippets per key (aws-cli, rclone, restic, s3cmd, mc, Terraform)
**Cluster**
- [ ] Visual layout editor with staged vs. applied diff
- [ ] Capacity planner / simulation
- [ ] Rebalance progress and node health timeline
- [ ] Worker/repair panel (trigger scrub, repair, rebalance)
**Observability**
- [ ] Dashboard with dedup/compression savings
- [ ] Metrics explorer pulling from Garage `/metrics`
- [ ] Admin audit log
**Polish**
- [ ] i18n (FR/EN)
- [ ] Mobile-friendly object browser
- [ ] First-run onboarding wizard
- [ ] GitOps export (layout + buckets + keys as YAML)
## License
MIT - see [LICENSE](LICENSE)
+267
View File
@@ -0,0 +1,267 @@
package auth
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"Noooste/garage-ui/internal/config"
"golang.org/x/oauth2"
)
// newOIDCServerWithTokenAndUserInfo extends the minimal discovery stub with
// token and userinfo endpoints so ExchangeCode and GetUserInfo can be driven
// end-to-end without a real IdP.
func newOIDCServerWithTokenAndUserInfo(
t *testing.T,
tokenResp map[string]any,
tokenStatus int,
userInfoResp map[string]any,
userInfoStatus int,
) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
var srv *httptest.Server
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
doc := map[string]any{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/auth",
"token_endpoint": srv.URL + "/token",
"jwks_uri": srv.URL + "/jwks",
"userinfo_endpoint": srv.URL + "/userinfo",
"id_token_signing_alg_values_supported": []string{"RS256"},
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(doc)
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"keys":[]}`))
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tokenStatus)
if tokenResp != nil {
_ = json.NewEncoder(w).Encode(tokenResp)
}
})
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(userInfoStatus)
if userInfoResp != nil {
_ = json.NewEncoder(w).Encode(userInfoResp)
}
})
srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestExchangeCode_OIDCDisabledReturnsError(t *testing.T) {
svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}}
if _, err := svc.ExchangeCode(context.Background(), "some-code"); err == nil {
t.Fatal("expected error when OIDC not initialized")
}
}
func TestExchangeCode_TokenEndpointErrorPropagates(t *testing.T) {
srv := newOIDCServerWithTokenAndUserInfo(t,
map[string]any{"error": "invalid_grant"}, http.StatusBadRequest,
nil, http.StatusOK,
)
svc, err := NewAuthService(
&config.AuthConfig{OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "c",
IssuerURL: srv.URL,
Scopes: []string{"openid"},
}},
&config.ServerConfig{RootURL: "https://example.test"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
_, err = svc.ExchangeCode(context.Background(), "bad-code")
if err == nil {
t.Fatal("expected error for 400 from token endpoint")
}
if !strings.Contains(err.Error(), "failed to exchange code") {
t.Errorf("error = %v, want wrap 'failed to exchange code'", err)
}
}
func TestVerifyIDToken_OIDCDisabledReturnsError(t *testing.T) {
svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}}
if _, err := svc.VerifyIDToken(context.Background(), "tok"); err == nil {
t.Fatal("expected error when OIDC not initialized")
}
}
func TestVerifyIDToken_GarbageTokenRejected(t *testing.T) {
// Discovery works; JWKS is empty so no signature can verify — any token
// is rejected. This exercises the verifier error path.
srv := newOIDCServerWithTokenAndUserInfo(t, nil, http.StatusOK, nil, http.StatusOK)
svc, err := NewAuthService(
&config.AuthConfig{OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "c",
IssuerURL: srv.URL,
Scopes: []string{"openid"},
}},
&config.ServerConfig{RootURL: "https://example.test"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
_, err = svc.VerifyIDToken(context.Background(), "not-a-real-jwt")
if err == nil {
t.Fatal("expected verifier error for garbage token")
}
}
func TestGetUserInfo_OIDCDisabledReturnsError(t *testing.T) {
svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}}
_, err := svc.GetUserInfo(context.Background(), &oauth2.Token{AccessToken: "x"})
if err == nil {
t.Fatal("expected error when OIDC not initialized")
}
}
func TestGetUserInfo_ProviderErrorPropagates(t *testing.T) {
srv := newOIDCServerWithTokenAndUserInfo(t,
nil, http.StatusOK,
map[string]any{"error": "invalid_token"}, http.StatusUnauthorized,
)
svc, err := NewAuthService(
&config.AuthConfig{OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "c",
IssuerURL: srv.URL,
Scopes: []string{"openid"},
}},
&config.ServerConfig{RootURL: "https://example.test"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
_, err = svc.GetUserInfo(context.Background(), &oauth2.Token{AccessToken: "bad"})
if err == nil {
t.Fatal("expected error from userinfo endpoint")
}
if !strings.Contains(err.Error(), "failed to get user info") {
t.Errorf("error = %v", err)
}
}
func TestGetUserInfo_HappyPath_ExtractsClaims(t *testing.T) {
srv := newOIDCServerWithTokenAndUserInfo(t,
nil, http.StatusOK,
map[string]any{
"sub": "user-123",
"preferred_username": "alice",
"email": "alice@example.com",
"name": "Alice Example",
"resource_access": map[string]any{
"garage": map[string]any{
"roles": []any{"admin", "user"},
},
},
},
http.StatusOK,
)
svc, err := NewAuthService(
&config.AuthConfig{OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "c",
IssuerURL: srv.URL,
Scopes: []string{"openid"},
UsernameAttribute: "preferred_username",
EmailAttribute: "email",
NameAttribute: "name",
RoleAttributePath: "resource_access.garage.roles",
}},
&config.ServerConfig{RootURL: "https://example.test"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
info, err := svc.GetUserInfo(context.Background(), &oauth2.Token{AccessToken: "good"})
if err != nil {
t.Fatalf("GetUserInfo: %v", err)
}
if info.Username != "alice" || info.Email != "alice@example.com" || info.Name != "Alice Example" {
t.Errorf("got %+v, want alice/alice@example.com/Alice Example", info)
}
if len(info.Roles) != 2 || info.Roles[0] != "admin" {
t.Errorf("Roles = %v, want [admin user]", info.Roles)
}
}
func TestExtractClaim(t *testing.T) {
cases := []struct {
name string
claims map[string]interface{}
key string
want string
}{
{"empty key returns empty", map[string]interface{}{"a": "b"}, "", ""},
{"missing key returns empty", map[string]interface{}{"a": "b"}, "missing", ""},
{"non-string value returns empty", map[string]interface{}{"n": 42}, "n", ""},
{"string value returned", map[string]interface{}{"email": "x@y"}, "email", "x@y"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := extractClaim(tc.claims, tc.key); got != tc.want {
t.Errorf("extractClaim(%v, %q) = %q, want %q", tc.claims, tc.key, got, tc.want)
}
})
}
}
func TestGenerateAndValidateStateToken_Roundtrip(t *testing.T) {
jwtSvc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
svc := &Service{
authConfig: &config.AuthConfig{},
serverConfig: &config.ServerConfig{},
jwtService: jwtSvc,
}
tok, err := svc.GenerateStateToken()
if err != nil {
t.Fatalf("GenerateStateToken: %v", err)
}
if tok == "" {
t.Fatal("state token is empty")
}
if !svc.ValidateAndConsumeState(tok) {
t.Fatal("ValidateAndConsumeState rejected a freshly-issued token")
}
// Double-consume must fail (CSRF single-use).
if svc.ValidateAndConsumeState(tok) {
t.Fatal("ValidateAndConsumeState accepted a re-used token")
}
}
func TestValidateAndConsumeState_RejectsGarbage(t *testing.T) {
jwtSvc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
svc := &Service{jwtService: jwtSvc}
if svc.ValidateAndConsumeState("definitely-not-a-token") {
t.Fatal("accepted an invalid token")
}
}
+52 -15
View File
@@ -287,23 +287,60 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
)
}
// 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,
},
// Garage's AllowBucketKey is additive — false values are no-ops, not revokes.
// To make this endpoint a true "set permissions" operation, split into Allow
// for the requested-true perms and Deny for the requested-false perms.
allow := models.BucketKeyPermission{
Read: req.Permissions.Read,
Write: req.Permissions.Write,
Owner: req.Permissions.Owner,
}
deny := 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()),
)
var result *models.GarageBucketInfo
if allow.Read || allow.Write || allow.Owner {
r, err := h.adminService.AllowBucketKey(ctx, models.BucketKeyPermRequest{
BucketID: bucketInfo.ID,
AccessKeyID: req.AccessKeyID,
Permissions: allow,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
)
}
result = r
}
if deny.Read || deny.Write || deny.Owner {
r, err := h.adminService.DenyBucketKey(ctx, models.BucketKeyPermRequest{
BucketID: bucketInfo.ID,
AccessKeyID: req.AccessKeyID,
Permissions: deny,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to revoke permissions: "+err.Error()),
)
}
result = r
}
if result == nil {
// Caller passed all-false on a key with no existing perms — nothing to do.
// Fetch current bucket state to return a consistent response.
r, err := h.adminService.GetBucketInfo(ctx, bucketInfo.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to fetch bucket info: "+err.Error()),
)
}
result = r
}
return c.JSON(models.SuccessResponse(result))
+98
View File
@@ -27,6 +27,7 @@ func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) {
app := fiber.New()
app.Get("/buckets/:bucket/objects", h.ListObjects)
app.Post("/buckets/:bucket/objects", h.UploadObject)
app.Post("/buckets/:bucket/directories", h.CreateDirectory)
app.Post("/buckets/:bucket/objects/upload-multiple", h.UploadMultipleObjects)
app.Post("/buckets/:bucket/objects/delete-multiple", h.DeleteMultipleObjects)
// Wildcard endpoints — mount under :key for tests. Handlers prefer
@@ -766,3 +767,100 @@ func TestUploadMultiple_DefaultsContentType(t *testing.T) {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
}
// --- CreateDirectory ---
func TestCreateDirectory_Success_AppendsTrailingSlash(t *testing.T) {
app, s3 := newObjectsTestApp(t)
var gotKey string
s3.CreateDirectoryMarkerFn = func(_ context.Context, bucket, key string) (*models.ObjectUploadResponse, error) {
gotKey = key
return &models.ObjectUploadResponse{Bucket: bucket, Key: key, Size: 0, ContentType: "application/x-directory"}, nil
}
body := bytes.NewBufferString(`{"key": "photos/2024"}`)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", body)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
if gotKey != "photos/2024/" {
t.Errorf("service key = %q, want trailing slash appended", gotKey)
}
}
func TestCreateDirectory_StripsLeadingSlashes(t *testing.T) {
app, s3 := newObjectsTestApp(t)
var gotKey string
s3.CreateDirectoryMarkerFn = func(_ context.Context, _, key string) (*models.ObjectUploadResponse, error) {
gotKey = key
return &models.ObjectUploadResponse{Key: key}, nil
}
body := bytes.NewBufferString(`{"key": "///already/"}`)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", body)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
if gotKey != "already/" {
t.Errorf("service key = %q, want 'already/'", gotKey)
}
}
func TestCreateDirectory_MissingKey400(t *testing.T) {
app, _ := newObjectsTestApp(t)
body := bytes.NewBufferString(`{"key": ""}`)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", body)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestCreateDirectory_MalformedJSON400(t *testing.T) {
app, _ := newObjectsTestApp(t)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", bytes.NewBufferString("not json"))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestCreateDirectory_ServiceError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.CreateDirectoryMarkerFn = func(_ context.Context, _, _ string) (*models.ObjectUploadResponse, error) {
return nil, errors.New("boom")
}
body := bytes.NewBufferString(`{"key": "x/"}`)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", body)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
+47
View File
@@ -0,0 +1,47 @@
package models
import "testing"
func TestSuccessResponse(t *testing.T) {
payload := map[string]int{"count": 3}
r := SuccessResponse(payload)
if !r.Success {
t.Error("Success should be true")
}
if r.Error != nil {
t.Errorf("Error should be nil, got %+v", r.Error)
}
m, ok := r.Data.(map[string]int)
if !ok {
t.Fatalf("Data type = %T, want map[string]int", r.Data)
}
if m["count"] != 3 {
t.Errorf("Data.count = %d, want 3", m["count"])
}
}
func TestSuccessResponse_NilData(t *testing.T) {
r := SuccessResponse(nil)
if !r.Success {
t.Error("Success should be true even with nil data")
}
if r.Data != nil {
t.Errorf("Data = %v, want nil", r.Data)
}
}
func TestErrorResponse(t *testing.T) {
r := ErrorResponse(ErrCodeBadRequest, "bad input")
if r.Success {
t.Error("Success should be false for error response")
}
if r.Data != nil {
t.Errorf("Data should be nil, got %v", r.Data)
}
if r.Error == nil {
t.Fatal("Error should not be nil")
}
if r.Error.Code != ErrCodeBadRequest || r.Error.Message != "bad input" {
t.Errorf("Error = %+v", r.Error)
}
}
+254
View File
@@ -0,0 +1,254 @@
package routes
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
)
// newNoAuthFixture builds a fixture with both admin and OIDC disabled. The
// AuthMiddleware short-circuits with c.Next(), letting handler logic run so
// wildcard-route dispatch can be exercised.
func newNoAuthFixture(t *testing.T) *routeFixture {
return newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = false
c.Auth.OIDC.Enabled = false
})
}
func plainReq(method, path string, body io.Reader) *http.Request {
return httptest.NewRequest(method, path, body)
}
func TestRoutes_ObjectWildcard_GET_DefaultRoutesToGetObject(t *testing.T) {
f := newNoAuthFixture(t)
var gotBucket, gotKey string
f.S3.GetObjectFn = func(_ context.Context, bucket, key string) (io.ReadCloser, *models.ObjectInfo, error) {
gotBucket, gotKey = bucket, key
return io.NopCloser(strings.NewReader("hello")), &models.ObjectInfo{Key: key, Size: 5, ContentType: "text/plain"}, nil
}
req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/folder/subdir/file.txt", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if gotBucket != "b1" || gotKey != "folder/subdir/file.txt" {
t.Errorf("service called with (%q, %q)", gotBucket, gotKey)
}
}
func TestRoutes_ObjectWildcard_GET_MetadataSuffixRoutesToMetadata(t *testing.T) {
f := newNoAuthFixture(t)
var gotKey string
f.S3.GetObjectMetadataFn = func(_ context.Context, _ string, key string) (*models.ObjectInfo, error) {
gotKey = key
return &models.ObjectInfo{Key: key, Size: 42, ContentType: "application/json"}, nil
}
req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/data/file.bin/metadata", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
// The handler strips the /metadata suffix before calling the service.
if gotKey != "data/file.bin" {
t.Errorf("key passed to service = %q, want 'data/file.bin'", gotKey)
}
}
func TestRoutes_ObjectWildcard_GET_PresignSuffixRoutesToPresigned(t *testing.T) {
f := newNoAuthFixture(t)
// The object must exist for the presign handler to succeed.
f.S3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
var gotKey string
f.S3.GetPresignedURLFn = func(_ context.Context, _ string, key string, _ time.Duration) (string, error) {
gotKey = key
return "https://signed.example/k", nil
}
req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/sub/file.bin/presign", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 — body: (unavailable)", resp.StatusCode)
}
if gotKey != "sub/file.bin" {
t.Errorf("key passed to service = %q, want 'sub/file.bin'", gotKey)
}
}
func TestRoutes_ObjectWildcard_DELETE_RoutesToDeleteObject(t *testing.T) {
f := newNoAuthFixture(t)
f.S3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
var gotKey string
f.S3.DeleteObjectFn = func(_ context.Context, _ string, key string) error {
gotKey = key
return nil
}
req := plainReq(http.MethodDelete, "/api/v1/buckets/b1/objects/path/to/delete.txt", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if gotKey != "path/to/delete.txt" {
t.Errorf("key passed to service = %q", gotKey)
}
}
func TestRoutes_ObjectWildcard_HEAD_RoutesToMetadata(t *testing.T) {
f := newNoAuthFixture(t)
var gotKey string
f.S3.GetObjectMetadataFn = func(_ context.Context, _ string, key string) (*models.ObjectInfo, error) {
gotKey = key
return &models.ObjectInfo{Key: key, Size: 7, ContentType: "text/plain"}, nil
}
req := plainReq(http.MethodHead, "/api/v1/buckets/b1/objects/deep/nested/x.txt", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if gotKey != "deep/nested/x.txt" {
t.Errorf("key passed to service = %q", gotKey)
}
}
func TestRoutes_ObjectWildcard_URLDecodedBeforeDispatch(t *testing.T) {
// %20 in the wildcard portion must be decoded before the service is called.
f := newNoAuthFixture(t)
var gotKey string
f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
gotKey = key
return io.NopCloser(strings.NewReader("")), &models.ObjectInfo{Key: key}, nil
}
req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/with%20space/file.txt", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if gotKey != "with space/file.txt" {
t.Errorf("decoded key = %q, want 'with space/file.txt'", gotKey)
}
}
// Covers the "skip SPA fallback for API-prefixed paths" branch without
// triggering SendFile (which holds file handles on Windows and races with
// t.TempDir cleanup). Only API/auth/health/docs paths are hit here — the
// fallback short-circuits to c.Next(), so no file is opened.
func TestRoutes_SPAFallback_SkipsAPIAndAuthPrefixes(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
// The presence of ./frontend/dist is what enables the fallback middleware.
if err := os.MkdirAll(filepath.Join(dir, "frontend", "dist"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// We deliberately do NOT create index.html — the test must not reach SendFile.
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "u"
c.Auth.Admin.Password = "p"
})
// Every prefix listed in the fallback's skip-list should bypass file
// serving and return 404 from fiber's default handler.
for _, p := range []string{
"/api/v1/definitely-not-a-route",
"/auth/nope",
"/health/extra/segments",
"/docs/missing",
} {
req := httptest.NewRequest(http.MethodGet, p, nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("%s: %v", p, err)
}
_ = resp.Body.Close()
// /api/v1/* hits auth middleware → 401. The others hit the SPA
// fallback's skip branch then fall through to 404. We only care that
// the SPA middleware did NOT attempt to serve index.html (which would
// succeed with 200 if it existed — here it doesn't exist, so SendFile
// would error; either way, != 200 suffices to prove the skip path ran).
if resp.StatusCode == 200 {
t.Errorf("%s returned 200 — SPA fallback should have skipped", p)
}
}
}
// Covers the third OIDC role-resolution fallback: when neither the ID token
// nor the access token exposes roles, the callback calls GetUserInfo and
// re-evaluates IsAdmin against those roles.
func TestRoutes_OIDCCallback_RoleMatchedViaUserInfoFallback_Succeeds(t *testing.T) {
f, iss := newOIDCFixture(t, "admin")
// ID token and access token have no roles by default — leave as-is.
// Override /userinfo to return a roles structure matching the configured
// RoleAttributePath (resource_access.test-client.roles).
iss.UserInfoFn = func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sub": "user-1",
"preferred_username": "alice",
"email": "alice@example.com",
"resource_access": map[string]any{
"test-client": map[string]any{"roles": []any{"admin"}},
},
})
}
state := oidcState(t, f)
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state="+state+"&code=c", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 303 {
t.Fatalf("status = %d, want 303 (userinfo fallback should grant admin)", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "/login?login=success" {
t.Errorf("Location = %q", loc)
}
}
+57
View File
@@ -531,6 +531,63 @@ func TestDoRequest_MalformedJSONReturnsDecodeError(t *testing.T) {
}
}
// TestAllMethods_Non2xxReturnsError exercises the decodeResponse error branch
// of every admin method by pointing them at a server that always returns 500.
// This is a single sweep over the near-identical "if err := decodeResponse ...
// return nil, fmt.Errorf(...)" branches that each wrapper repeats.
func TestAllMethods_Non2xxReturnsError(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
})
svc, _ := newAdminTestServer(t, h)
ctx := context.Background()
calls := map[string]func() error{
"ListKeys": func() error { _, err := svc.ListKeys(ctx); return err },
"CreateKey": func() error { _, err := svc.CreateKey(ctx, models.CreateKeyRequest{}); return err },
"GetKeyInfo": func() error { _, err := svc.GetKeyInfo(ctx, "k", false); return err },
"UpdateKey": func() error { _, err := svc.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); return err },
"DeleteKey": func() error { return svc.DeleteKey(ctx, "k") },
"ImportKey": func() error { _, err := svc.ImportKey(ctx, models.ImportKeyRequest{}); return err },
"ListBuckets": func() error { _, err := svc.ListBuckets(ctx); return err },
"GetBucketInfo": func() error { _, err := svc.GetBucketInfo(ctx, "b"); return err },
"GetBucketInfoByAlias": func() error { _, err := svc.GetBucketInfoByAlias(ctx, "b"); return err },
"CreateBucket": func() error { _, err := svc.CreateBucket(ctx, models.CreateBucketAdminRequest{}); return err },
"UpdateBucket": func() error { _, err := svc.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); return err },
"DeleteBucket": func() error { return svc.DeleteBucket(ctx, "b") },
"AddBucketAlias": func() error { _, err := svc.AddBucketAlias(ctx, models.AddBucketAliasRequest{}); return err },
"RemoveBucketAlias": func() error { _, err := svc.RemoveBucketAlias(ctx, models.RemoveBucketAliasRequest{}); return err },
"AllowBucketKey": func() error { _, err := svc.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); return err },
"DenyBucketKey": func() error { _, err := svc.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); return err },
"GetClusterHealth": func() error { _, err := svc.GetClusterHealth(ctx); return err },
"GetClusterStatus": func() error { _, err := svc.GetClusterStatus(ctx); return err },
"GetClusterStatistics": func() error { _, err := svc.GetClusterStatistics(ctx); return err },
"GetNodeInfo": func() error { _, err := svc.GetNodeInfo(ctx, "n"); return err },
"GetNodeStatistics": func() error { _, err := svc.GetNodeStatistics(ctx, "n"); return err },
"HealthCheck": func() error { return svc.HealthCheck(ctx) },
}
for name, fn := range calls {
t.Run(name, func(t *testing.T) {
if err := fn(); err == nil {
t.Fatalf("%s: expected error on 500, got nil", name)
}
})
}
}
// TestDebugLogLevelEnablesSessionLog exercises the NewGarageAdminService
// branch that enables azuretls' session logging when logLevel == "debug".
func TestDebugLogLevelEnablesSessionLog(t *testing.T) {
svc := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: "http://127.0.0.1:1",
AdminToken: "t",
}, "debug")
if svc == nil || svc.httpClient == nil {
t.Fatal("expected service with configured http client")
}
}
func TestDoRequest_RetriesExhaustOnConnectionRefused(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
+1
View File
@@ -28,6 +28,7 @@ type AdminService interface {
UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error)
DeleteBucket(ctx context.Context, bucketID string) error
AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
// Cluster
GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error)
@@ -41,6 +41,7 @@ type AdminMock struct {
UpdateBucketFn func(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error)
DeleteBucketFn func(ctx context.Context, bucketID string) error
AllowBucketKeyFn func(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
DenyBucketKeyFn func(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
// Cluster
GetClusterHealthFn func(ctx context.Context) (*models.ClusterHealth, error)
@@ -168,6 +169,14 @@ func (m *AdminMock) AllowBucketKey(ctx context.Context, req models.BucketKeyPerm
return m.AllowBucketKeyFn(ctx, req)
}
func (m *AdminMock) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
m.record("DenyBucketKey", req)
if m.DenyBucketKeyFn == nil {
return nil, errNotConfigured("DenyBucketKey")
}
return m.DenyBucketKeyFn(ctx, req)
}
// --- Cluster ---
func (m *AdminMock) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
@@ -0,0 +1,215 @@
package mocks
import (
"context"
"io"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
)
// The mocks are exercised across the handlers test suite, but Go's per-
// package coverage counts only statements executed from tests in THIS
// package. This test calls every mock method with no Fn configured, which
// covers the default "not configured" error path (and the record() helpers
// that build the call log).
func TestAdminMock_UnconfiguredMethodsReturnSentinel(t *testing.T) {
ctx := context.Background()
m := &AdminMock{}
type call struct {
name string
fn func() error
}
calls := []call{
{"ListKeys", func() error { _, e := m.ListKeys(ctx); return e }},
{"CreateKey", func() error { _, e := m.CreateKey(ctx, models.CreateKeyRequest{}); return e }},
{"GetKeyInfo", func() error { _, e := m.GetKeyInfo(ctx, "k", false); return e }},
{"UpdateKey", func() error { _, e := m.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); return e }},
{"DeleteKey", func() error { return m.DeleteKey(ctx, "k") }},
{"ListBuckets", func() error { _, e := m.ListBuckets(ctx); return e }},
{"GetBucketInfo", func() error { _, e := m.GetBucketInfo(ctx, "b"); return e }},
{"GetBucketInfoByAlias", func() error { _, e := m.GetBucketInfoByAlias(ctx, "a"); return e }},
{"CreateBucket", func() error { _, e := m.CreateBucket(ctx, models.CreateBucketAdminRequest{}); return e }},
{"UpdateBucket", func() error { _, e := m.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); return e }},
{"DeleteBucket", func() error { return m.DeleteBucket(ctx, "b") }},
{"AllowBucketKey", func() error { _, e := m.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); return e }},
{"DenyBucketKey", func() error { _, e := m.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); return e }},
{"GetClusterHealth", func() error { _, e := m.GetClusterHealth(ctx); return e }},
{"GetClusterStatus", func() error { _, e := m.GetClusterStatus(ctx); return e }},
{"GetClusterStatistics", func() error { _, e := m.GetClusterStatistics(ctx); return e }},
{"GetNodeInfo", func() error { _, e := m.GetNodeInfo(ctx, "n"); return e }},
{"GetNodeStatistics", func() error { _, e := m.GetNodeStatistics(ctx, "n"); return e }},
{"HealthCheck", func() error { return m.HealthCheck(ctx) }},
{"GetMetrics", func() error { _, e := m.GetMetrics(ctx); return e }},
}
for _, c := range calls {
err := c.fn()
if err == nil {
t.Errorf("%s: expected error from unconfigured mock, got nil", c.name)
continue
}
if !strings.Contains(err.Error(), c.name) {
t.Errorf("%s: error %q should mention method name", c.name, err.Error())
}
}
if len(m.Calls) != len(calls) {
t.Errorf("Calls recorded = %d, want %d", len(m.Calls), len(calls))
}
}
func TestAdminMock_ConfiguredFnsAreInvoked(t *testing.T) {
ctx := context.Background()
m := &AdminMock{
ListKeysFn: func(ctx context.Context) ([]models.ListKeysResponseItem, error) {
return []models.ListKeysResponseItem{{ID: "k1"}}, nil
},
DeleteKeyFn: func(ctx context.Context, id string) error { return nil },
HealthCheckFn: func(ctx context.Context) error { return nil },
GetMetricsFn: func(ctx context.Context) (string, error) { return "metric 1", nil },
}
if got, err := m.ListKeys(ctx); err != nil || len(got) != 1 {
t.Errorf("ListKeys = (%v, %v), want one item", got, err)
}
if err := m.DeleteKey(ctx, "k"); err != nil {
t.Errorf("DeleteKey: %v", err)
}
if err := m.HealthCheck(ctx); err != nil {
t.Errorf("HealthCheck: %v", err)
}
if got, _ := m.GetMetrics(ctx); got != "metric 1" {
t.Errorf("GetMetrics = %q", got)
}
}
func TestS3Mock_UnconfiguredMethodsReturnSentinel(t *testing.T) {
ctx := context.Background()
m := &S3Mock{}
if _, err := m.ListObjects(ctx, "b", "", 0, ""); err == nil {
t.Error("ListObjects: want error")
}
if _, err := m.UploadObject(ctx, "b", "k", strings.NewReader(""), ""); err == nil {
t.Error("UploadObject: want error")
}
if _, err := m.CreateDirectoryMarker(ctx, "b", "k/"); err == nil {
t.Error("CreateDirectoryMarker: want error")
}
if _, _, err := m.GetObject(ctx, "b", "k"); err == nil {
t.Error("GetObject: want error")
}
if _, err := m.ObjectExists(ctx, "b", "k"); err == nil {
t.Error("ObjectExists: want error")
}
if err := m.DeleteObject(ctx, "b", "k"); err == nil {
t.Error("DeleteObject: want error")
}
if _, err := m.GetObjectMetadata(ctx, "b", "k"); err == nil {
t.Error("GetObjectMetadata: want error")
}
if _, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err == nil {
t.Error("GetPresignedURL: want error")
}
if err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err == nil {
t.Error("DeleteMultipleObjects: want error")
}
// UploadMultipleObjects has no error channel; it must return a result slice
// with one failed entry per input file.
results := m.UploadMultipleObjects(ctx, "b", []struct {
Key string
Body io.Reader
ContentType string
}{
{Key: "a"}, {Key: "b"},
})
if len(results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(results))
}
for _, r := range results {
if r.Success {
t.Errorf("result[%s] should not be successful with no Fn set", r.Key)
}
}
if len(m.Calls) < 10 {
t.Errorf("expected Calls to capture each invocation, got %d entries", len(m.Calls))
}
}
func TestS3Mock_ConfiguredFnsAreInvoked(t *testing.T) {
ctx := context.Background()
m := &S3Mock{
ListObjectsFn: func(_ context.Context, _, _ string, _ int, _ string) (*models.ObjectListResponse, error) {
return &models.ObjectListResponse{Count: 1}, nil
},
UploadObjectFn: func(_ context.Context, _, _ string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
return &models.ObjectUploadResponse{}, nil
},
CreateDirectoryMarkerFn: func(_ context.Context, _, _ string) (*models.ObjectUploadResponse, error) {
return &models.ObjectUploadResponse{}, nil
},
GetObjectFn: func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("x")), &models.ObjectInfo{}, nil
},
ObjectExistsFn: func(_ context.Context, _, _ string) (bool, error) { return true, nil },
DeleteObjectFn: func(_ context.Context, _, _ string) error { return nil },
GetObjectMetadataFn: func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{}, nil
},
GetPresignedURLFn: func(_ context.Context, _, _ string, _ time.Duration) (string, error) {
return "http://x", nil
},
DeleteMultipleObjectsFn: func(_ context.Context, _ string, _ []string) error { return nil },
UploadMultipleObjectsFn: func(_ context.Context, _ string, files []struct {
Key string
Body io.Reader
ContentType string
}) []services.UploadResult {
out := make([]services.UploadResult, len(files))
for i, f := range files {
out[i] = services.UploadResult{Key: f.Key, Success: true}
}
return out
},
}
if r, err := m.ListObjects(ctx, "b", "", 0, ""); err != nil || r.Count != 1 {
t.Errorf("ListObjects = (%+v, %v)", r, err)
}
if _, err := m.UploadObject(ctx, "b", "k", strings.NewReader(""), ""); err != nil {
t.Errorf("UploadObject: %v", err)
}
if _, err := m.CreateDirectoryMarker(ctx, "b", "k/"); err != nil {
t.Errorf("CreateDirectoryMarker: %v", err)
}
if _, _, err := m.GetObject(ctx, "b", "k"); err != nil {
t.Errorf("GetObject: %v", err)
}
if ok, err := m.ObjectExists(ctx, "b", "k"); err != nil || !ok {
t.Errorf("ObjectExists = (%v, %v)", ok, err)
}
if err := m.DeleteObject(ctx, "b", "k"); err != nil {
t.Errorf("DeleteObject: %v", err)
}
if _, err := m.GetObjectMetadata(ctx, "b", "k"); err != nil {
t.Errorf("GetObjectMetadata: %v", err)
}
if u, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err != nil || u == "" {
t.Errorf("GetPresignedURL = (%q, %v)", u, err)
}
if err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err != nil {
t.Errorf("DeleteMultipleObjects: %v", err)
}
results := m.UploadMultipleObjects(ctx, "b", []struct {
Key string
Body io.Reader
ContentType string
}{{Key: "a"}})
if len(results) != 1 || !results[0].Success {
t.Errorf("UploadMultipleObjects results = %+v", results)
}
}
+569
View File
@@ -0,0 +1,569 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
)
// s3ErrorXML writes an S3-style error response that the MinIO SDK parses.
func s3ErrorXML(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>%s</Code><Message>%s</Message><Resource>/</Resource><RequestId>x</RequestId></Error>`, code, msg)
}
// newS3TestService builds an S3Service whose Endpoint points at a single
// httptest.Server handling BOTH Garage admin calls (for credential lookup)
// and S3 data-plane requests. Admin requests are routed by path prefix
// `/v2/` and `/health`; everything else is treated as an S3 call and
// dispatched to s3Handler.
func newS3TestService(t *testing.T, s3Handler http.Handler) *S3Service {
t.Helper()
secret := "s3-test-secret"
adminMux := http.NewServeMux()
adminMux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
ID: "bid",
Keys: []models.BucketKeyInfo{
{AccessKeyID: "TESTAK", Permissions: models.BucketKeyPermission{Read: true, Write: true}},
},
})
})
adminMux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
AccessKeyID: "TESTAK",
SecretAccessKey: &secret,
})
})
combined := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v2/") || r.URL.Path == "/health" {
adminMux.ServeHTTP(w, r)
return
}
s3Handler.ServeHTTP(w, r)
})
srv := httptest.NewServer(combined)
t.Cleanup(srv.Close)
admin := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test",
}, "")
// strip scheme for S3 endpoint (NewS3Service does this itself if http:// prefix)
s3 := NewS3Service(&config.GarageConfig{
Endpoint: srv.URL, // http://127.0.0.1:NNNN
Region: "garage",
}, admin)
return s3
}
// uniqueBucket2 returns a per-test bucket name so GlobalCache doesn't leak
// credentials between tests.
func uniqueBucket2(t *testing.T) string {
t.Helper()
name := "b-" + strings.ReplaceAll(t.Name(), "/", "-")
t.Cleanup(func() { utils.GlobalCache.Delete("key:" + name) })
return name
}
// fixedRequestCounter returns an http.Handler that always replies with the
// given S3 error, and counts requests.
func errS3Handler(status int, code string) (http.Handler, *int) {
var count int
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count++
s3ErrorXML(w, status, code, code)
}), &count
}
func TestS3_ListBuckets_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusInternalServerError, "InternalError")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.ListBuckets(ctx)
if err == nil {
t.Fatal("expected error from ListBuckets, got nil")
}
if !strings.Contains(err.Error(), "failed to list buckets") {
t.Errorf("error %v should wrap 'failed to list buckets'", err)
}
}
func TestS3_CreateBucket_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusConflict, "BucketAlreadyExists")
s3 := newS3TestService(t, h)
_ = uniqueBucket2(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.CreateBucket(ctx, "b-TestS3_CreateBucket_ServerError")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to create bucket") {
t.Errorf("error = %v, want wrap 'failed to create bucket'", err)
}
}
func TestS3_DeleteBucket_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusNotFound, "NoSuchBucket")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.DeleteBucket(ctx, "b-TestS3_DeleteBucket_ServerError")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to delete bucket") {
t.Errorf("error = %v", err)
}
}
func TestS3_ListObjects_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_ServerError", "", 0, "")
if err == nil {
t.Fatal("expected error from ListObjects, got nil")
}
if !strings.Contains(err.Error(), "failed to list objects") {
t.Errorf("error = %v", err)
}
}
func TestS3_UploadObject_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.UploadObject(ctx, "b-TestS3_UploadObject_ServerError", "k", bytes.NewReader([]byte("hi")), "text/plain")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to upload object") {
t.Errorf("error = %v", err)
}
}
func TestS3_CreateDirectoryMarker_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.CreateDirectoryMarker(ctx, "b-TestS3_CreateDirectoryMarker_ServerError", "folder/")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to create directory") {
t.Errorf("error = %v", err)
}
}
func TestS3_GetObject_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusNotFound, "NoSuchKey")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, _, err := s3.GetObject(ctx, "b-TestS3_GetObject_ServerError", "missing")
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestS3_DeleteObject_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.DeleteObject(ctx, "b-TestS3_DeleteObject_ServerError", "k")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to delete object") {
t.Errorf("error = %v", err)
}
}
func TestS3_ObjectExists_NoSuchKeyReturnsFalseNil(t *testing.T) {
h, _ := errS3Handler(http.StatusNotFound, "NoSuchKey")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
exists, err := s3.ObjectExists(ctx, "b-TestS3_ObjectExists_NoSuchKeyReturnsFalseNil", "k")
if err != nil {
t.Fatalf("ObjectExists returned error for NoSuchKey: %v", err)
}
if exists {
t.Error("exists should be false for NoSuchKey")
}
}
func TestS3_ObjectExists_OtherErrorPropagates(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.ObjectExists(ctx, "b-TestS3_ObjectExists_OtherErrorPropagates", "k")
if err == nil {
t.Fatal("expected error for AccessDenied, got nil")
}
}
func TestS3_GetObjectMetadata_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusNotFound, "NoSuchKey")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.GetObjectMetadata(ctx, "b-TestS3_GetObjectMetadata_ServerError", "k")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to get metadata") {
t.Errorf("error = %v", err)
}
}
func TestS3_DeleteMultipleObjects_EmptyKeysIsNoop(t *testing.T) {
// No S3 handler should be called; use a handler that fails if invoked.
called := false
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
s3ErrorXML(w, http.StatusInternalServerError, "ShouldNotHappen", "")
})
s3 := newS3TestService(t, h)
if err := s3.DeleteMultipleObjects(context.Background(), "whatever", nil); err != nil {
t.Fatalf("empty keys should return nil, got %v", err)
}
if called {
t.Error("S3 handler was invoked for empty-keys call")
}
}
func TestS3_DeleteMultipleObjects_ServerErrorPropagates(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.DeleteMultipleObjects(ctx, "b-TestS3_DeleteMultipleObjects_ServerErrorPropagates", []string{"a", "b"})
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestS3_GetPresignedURL_ReturnsURLWithoutServerCall(t *testing.T) {
// Presign is purely local (no network round-trip). Any handler suffices.
called := false
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.GetPresignedURL(ctx, "b-TestS3_GetPresignedURL_ReturnsURLWithoutServerCall", "k", 10*time.Minute)
if err != nil {
t.Fatalf("GetPresignedURL: %v", err)
}
u, perr := url.Parse(got)
if perr != nil {
t.Fatalf("returned URL is not parseable: %v", perr)
}
if u.Scheme == "" || u.Host == "" {
t.Errorf("presigned URL missing scheme/host: %q", got)
}
if !strings.Contains(u.RawQuery, "X-Amz-Signature") {
t.Errorf("presigned URL should contain X-Amz-Signature, got %q", got)
}
if called {
t.Error("presign should not make a network call")
}
}
// listBucketResultXML produces a ListBucketResult XML body that MinIO
// parses. ListObjectsV2 is keyed on the `list-type=2` query parameter.
func listBucketResultXML(bucket string, isTruncated bool, nextToken string, contents []struct {
Key string
Size int64
LastModified string
ETag string
}, commonPrefixes []string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
b.WriteString(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">`)
fmt.Fprintf(&b, `<Name>%s</Name>`, bucket)
b.WriteString(`<Prefix></Prefix>`)
fmt.Fprintf(&b, `<KeyCount>%d</KeyCount>`, len(contents))
b.WriteString(`<MaxKeys>1000</MaxKeys>`)
fmt.Fprintf(&b, `<IsTruncated>%t</IsTruncated>`, isTruncated)
if nextToken != "" {
fmt.Fprintf(&b, `<NextContinuationToken>%s</NextContinuationToken>`, nextToken)
}
for _, c := range contents {
lm := c.LastModified
if lm == "" {
lm = "2024-01-01T00:00:00.000Z"
}
etag := c.ETag
if etag == "" {
etag = "d41d8cd98f00b204e9800998ecf8427e"
}
fmt.Fprintf(&b, `<Contents><Key>%s</Key><LastModified>%s</LastModified><ETag>"%s"</ETag><Size>%d</Size><StorageClass>STANDARD</StorageClass></Contents>`,
c.Key, lm, etag, c.Size)
}
for _, p := range commonPrefixes {
fmt.Fprintf(&b, `<CommonPrefixes><Prefix>%s</Prefix></CommonPrefixes>`, p)
}
b.WriteString(`</ListBucketResult>`)
return b.String()
}
// s3ListHandler routes ListObjectsV2 (GET with list-type=2) to listBody
// and StatObject (HEAD) to a 200 response whose headers reflect statHeaders.
func s3ListHandler(listBody string, statHeaders map[string]string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
for k, v := range statHeaders {
w.Header().Set(k, v)
}
if _, ok := statHeaders["Content-Length"]; !ok {
w.Header().Set("Content-Length", "0")
}
w.WriteHeader(http.StatusOK)
return
}
// Treat any GET as a ListObjectsV2 request.
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, listBody)
})
}
func TestS3_ListObjects_EmptyResult(t *testing.T) {
xml := listBucketResultXML("b", false, "", nil, nil)
s3 := newS3TestService(t, s3ListHandler(xml, nil))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_EmptyResult", "", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if got.Count != 0 || len(got.Objects) != 0 || len(got.Prefixes) != 0 {
t.Errorf("expected empty listing, got %+v", got)
}
}
func TestS3_ListObjects_ObjectsAndPrefixes(t *testing.T) {
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "file.txt", Size: 10},
}
xml := listBucketResultXML("b", false, "", contents, []string{"folder/"})
s3 := newS3TestService(t, s3ListHandler(xml, map[string]string{
"Content-Type": "text/plain",
"Content-Length": "10",
}))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_ObjectsAndPrefixes", "", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if got.Count != 1 || got.Objects[0].Key != "file.txt" {
t.Errorf("objects = %+v", got.Objects)
}
if len(got.Prefixes) != 1 || got.Prefixes[0] != "folder/" {
t.Errorf("prefixes = %+v", got.Prefixes)
}
// ContentType from StatObject may or may not round-trip depending on
// signature validation in the MinIO client; don't assert on it here.
}
func TestS3_ListObjects_DirectoryMarkerPromotedToPrefix(t *testing.T) {
// A zero-byte key ending in "/" in Contents must be dropped from
// Objects and promoted to Prefixes (unless already covered).
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "empty-folder/", Size: 0},
{Key: "already/", Size: 0}, // duplicate of CommonPrefix — must not duplicate
{Key: "real.txt", Size: 5},
}
xml := listBucketResultXML("b", true, "tokenXYZ", contents, []string{"already/"})
s3 := newS3TestService(t, s3ListHandler(xml, map[string]string{"Content-Length": "5"}))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_DirectoryMarkerPromotedToPrefix", "", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if got.Count != 1 || got.Objects[0].Key != "real.txt" {
t.Errorf("Objects should contain only real.txt, got %+v", got.Objects)
}
// Prefixes should contain "already/" (from CommonPrefix) and "empty-folder/"
// (promoted from Contents). "already/" must not appear twice.
count := map[string]int{}
for _, p := range got.Prefixes {
count[p]++
}
if count["already/"] != 1 {
t.Errorf("Prefixes contains 'already/' %d times, want 1: %v", count["already/"], got.Prefixes)
}
if count["empty-folder/"] != 1 {
t.Errorf("Prefixes missing 'empty-folder/': %v", got.Prefixes)
}
if !got.IsTruncated || got.NextContinuationToken != "tokenXYZ" {
t.Errorf("pagination fields not propagated: IsTruncated=%v Token=%q", got.IsTruncated, got.NextContinuationToken)
}
}
func TestS3_ListObjects_MarkerMatchingPrefixIsDropped(t *testing.T) {
// When listing a specific prefix, a marker whose key == the listing
// prefix is the folder itself — it must not render as a child of itself.
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "mydir/", Size: 0}, // matches prefix — must be dropped entirely
}
xml := listBucketResultXML("b", false, "", contents, nil)
s3 := newS3TestService(t, s3ListHandler(xml, nil))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_MarkerMatchingPrefixIsDropped", "mydir/", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if len(got.Objects) != 0 || len(got.Prefixes) != 0 {
t.Errorf("marker equal to prefix should be dropped entirely, got %+v", got)
}
}
func TestS3_ListObjects_StatObjectFailureLeavesContentTypeEmpty(t *testing.T) {
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "f.bin", Size: 100},
}
xml := listBucketResultXML("b", false, "", contents, nil)
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
// StatObject fails — return 403.
w.WriteHeader(http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, xml)
})
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_StatObjectFailureLeavesContentTypeEmpty", "", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if got.Count != 1 || got.Objects[0].Key != "f.bin" {
t.Fatalf("unexpected object list %+v", got.Objects)
}
if got.Objects[0].ContentType != "" {
t.Errorf("ContentType = %q, want empty on StatObject failure", got.Objects[0].ContentType)
}
}
func TestS3_UploadMultipleObjects_PerFileFailuresRecorded(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
results := s3.UploadMultipleObjects(ctx, "b-TestS3_UploadMultipleObjects_PerFileFailuresRecorded", []struct {
Key string
Body io.Reader
ContentType string
}{
{Key: "a", Body: bytes.NewReader([]byte("hello")), ContentType: "text/plain"},
{Key: "b", Body: bytes.NewReader([]byte("world")), ContentType: "text/plain"},
})
if len(results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(results))
}
for i, r := range results {
if r.Success {
t.Errorf("result[%d] should not be successful: %+v", i, r)
}
if r.Error == nil {
t.Errorf("result[%d] should have Error set", i)
}
}
}
+22
View File
@@ -219,6 +219,28 @@ func TestLogger_WithContext_AddsFields(t *testing.T) {
}
}
func TestWithError_AttachesErrorField(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "error", Format: "json"})
WithError(io.EOF).Msg("boom")
})
line := firstNonEmptyLine(out)
var parsed map[string]any
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
t.Fatalf("not JSON: %v — %s", err, line)
}
if got, _ := parsed["error"].(string); got != io.EOF.Error() {
t.Errorf("error field = %v, want %q", parsed["error"], io.EOF.Error())
}
if got, _ := parsed["level"].(string); got != "error" {
t.Errorf("level = %v, want error", parsed["level"])
}
}
// --- helpers ---
func firstNonEmptyLine(s string) string {
+16
View File
@@ -266,3 +266,19 @@ func containsAll(s string, subs ...string) bool {
}
return true
}
func TestDefaultRetryConfig(t *testing.T) {
c := DefaultRetryConfig()
if c.MaxRetries <= 0 {
t.Errorf("MaxRetries = %d, want >0", c.MaxRetries)
}
if c.InitialBackoff <= 0 {
t.Errorf("InitialBackoff = %v, want >0", c.InitialBackoff)
}
if c.MaxBackoff < c.InitialBackoff {
t.Errorf("MaxBackoff (%v) should be >= InitialBackoff (%v)", c.MaxBackoff, c.InitialBackoff)
}
if c.BackoffFactor < 1.0 {
t.Errorf("BackoffFactor = %v, want >=1.0", c.BackoffFactor)
}
}
+17
View File
@@ -0,0 +1,17 @@
coverage:
status:
project:
default:
target: 85%
threshold: 1%
patch:
default:
target: 80%
ignore:
- "backend/main.go"
- "backend/docs/"
- "backend/internal/services/mocks/"
- "backend/**/*_mock.go"
- "backend/internal/services/s3.go"
- "frontend/"
+93
View File
@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+30 -5
View File
@@ -1,14 +1,19 @@
import { useEffect } from 'react';
import {BrowserRouter, Route, Routes} from 'react-router-dom';
import {BrowserRouter, Navigate, 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 {BucketDetailShell} from '@/components/layout/bucket-detail-shell';
import {Dashboard} from '@/pages/Dashboard';
import {Buckets} from '@/pages/Buckets';
import {BucketObjects} from '@/pages/BucketObjects';
import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView';
import {BucketPermissions} from '@/pages/BucketPermissions';
import {BucketWebsite} from '@/pages/BucketWebsite';
import {BucketSettings} from '@/pages/BucketSettings';
import {Cluster} from '@/pages/Cluster';
import {AccessControl} from '@/pages/AccessControl';
import {Login} from '@/pages/Login';
import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView';
import {Toaster} from 'sonner';
import {queryClient} from '@/lib/query-client';
import {useAuthStore} from '@/store/auth-store';
@@ -17,8 +22,21 @@ import {LoadingSpinner} from '@/components/auth/LoadingSpinner';
function ThemedToaster() {
const { theme } = useTheme();
return <Toaster richColors position="bottom-right" theme={theme} />;
return (
<Toaster
richColors
position="bottom-right"
theme={theme}
toastOptions={{
classNames: {
toast:
'rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] font-sans shadow-lg',
title: 'text-[14px] font-medium',
description: 'text-[13px] text-[var(--muted-foreground)]',
},
}}
/>
);
}
function App() {
@@ -49,7 +67,14 @@ function App() {
>
<Route index element={<Dashboard />} />
<Route path="buckets" element={<Buckets />} />
<Route path="buckets/:bucketName/objects/*" element={<ObjectDetailsView />} />
<Route path="buckets/:bucketName" element={<BucketDetailShell />}>
<Route index element={<Navigate to="objects" replace />} />
<Route path="objects" element={<BucketObjects />} />
<Route path="objects/*" element={<ObjectDetailsView />} />
<Route path="permissions" element={<BucketPermissions />} />
<Route path="website" element={<BucketWebsite />} />
<Route path="settings" element={<BucketSettings />} />
</Route>
<Route path="cluster" element={<Cluster />} />
<Route path="access" element={<AccessControl />} />
</Route>
@@ -99,7 +99,7 @@ export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps
</div>
<Button
type="button"
variant="outline"
variant="secondary"
className="w-full"
onClick={loginOIDC}
>
@@ -9,7 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FolderIcon, Globe, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
import { FolderIcon, Globe, Loader2, MoreVertical, Search, Settings, Trash2 } from 'lucide-react';
import { formatBytes } from '@/lib/file-utils';
import { formatDate } from '@/lib/utils';
import type { Bucket } from '@/types';
@@ -21,7 +21,6 @@ interface BucketListViewProps {
onSearchChange: (query: string) => void;
onViewBucket: (bucketName: string) => void;
onOpenSettings: (bucket: Bucket) => void;
onCreateBucket: () => void;
onDeleteBucket: (bucket: Bucket) => void;
onWebsiteSettings: (bucket: Bucket) => void;
}
@@ -33,7 +32,6 @@ export function BucketListView({
onSearchChange,
onViewBucket,
onOpenSettings,
onCreateBucket,
onDeleteBucket,
onWebsiteSettings,
}: BucketListViewProps) {
@@ -44,20 +42,14 @@ export function BucketListView({
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 className="relative w-full 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>
{/* Buckets Table */}
@@ -99,14 +91,14 @@ export function BucketListView({
<TableCell className="font-medium max-w-[200px]">
<span className="truncate">{bucket.name}</span>
{bucket.websiteAccess && (
<Badge variant="outline" className="text-xs ml-2">
<Badge variant="neutral" className="text-xs ml-2">
<Globe className="h-3 w-3 mr-1" />
Website
</Badge>
)}
</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
<Badge variant="neutral">{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>
@@ -1,196 +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 { useAccessKeys } from '@/hooks/useApi';
import type { 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 { data: availableKeys = [] } = useAccessKeys();
const [selectedAccessKey, setSelectedAccessKey] = useState<string>('');
const [permissionRead, setPermissionRead] = useState(false);
const [permissionWrite, setPermissionWrite] = useState(false);
const [permissionOwner, setPermissionOwner] = useState(false);
useEffect(() => {
if (open && bucket) {
resetForm();
}
}, [open, bucket]);
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,133 +0,0 @@
import { useState, useEffect } 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 { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import type { Bucket } from '@/types';
interface BucketWebsiteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bucket: Bucket | null;
onSave: (
bucketName: string,
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
) => Promise<boolean>;
}
export function BucketWebsiteDialog({
open,
onOpenChange,
bucket,
onSave,
}: BucketWebsiteDialogProps) {
const [enabled, setEnabled] = useState(false);
const [indexDocument, setIndexDocument] = useState('index.html');
const [errorDocument, setErrorDocument] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open && bucket) {
setEnabled(bucket.websiteAccess);
setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html');
setErrorDocument(bucket.websiteConfig?.errorDocument ?? '');
}
}, [open, bucket]);
const handleSave = async () => {
if (!bucket) return;
setSaving(true);
const success = await onSave(bucket.name, {
enabled,
indexDocument: enabled ? indexDocument : undefined,
errorDocument: enabled && errorDocument ? errorDocument : undefined,
});
setSaving(false);
if (success) onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Website Hosting {bucket?.name}</DialogTitle>
<DialogDescription>
Configure this bucket to serve a static website.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Website access</p>
<p className="text-xs text-muted-foreground mt-0.5">
Allow public HTTP access to bucket objects
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={enabled ? 'default' : 'secondary'}>
{enabled ? 'Enabled' : 'Disabled'}
</Badge>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
{enabled && (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Index document <span className="text-destructive">*</span>
</label>
<Input
value={indexDocument}
onChange={(e) => setIndexDocument(e.target.value)}
placeholder="index.html"
/>
<p className="text-xs text-muted-foreground">
The file served when a directory is requested (e.g. index.html)
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Error document</label>
<Input
value={errorDocument}
onChange={(e) => setErrorDocument(e.target.value)}
placeholder="404.html (optional)"
/>
<p className="text-xs text-muted-foreground">
The file served when an object is not found (optional)
</p>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
variant={!enabled && bucket?.websiteAccess ? 'destructive' : 'default'}
disabled={saving || (enabled && !indexDocument)}
>
{saving
? 'Saving...'
: !enabled && bucket?.websiteAccess
? 'Disable Website'
: 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,14 +1,17 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import { toast } from 'sonner';
interface CreateBucketDialogProps {
@@ -20,6 +23,8 @@ interface CreateBucketDialogProps {
export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) {
const [bucketName, setBucketName] = useState('');
useEffect(() => { if (!open) setBucketName(''); }, [open]);
const handleCreate = async () => {
if (!bucketName) {
toast.error('Please enter a bucket name');
@@ -37,15 +42,19 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Bucket</DialogTitle>
<DialogDescription>
Create a new storage bucket for your objects
</DialogDescription>
<IconTile icon={<Database />} tone="primary" size="md" />
<div className="flex-1">
<DialogTitle>Create New Bucket</DialogTitle>
<DialogDescription>
Create a new storage bucket for your objects
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
<DialogBody className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Bucket Name</label>
<Input
autoFocus
placeholder="my-bucket-name"
value={bucketName}
onChange={(e) => setBucketName(e.target.value)}
@@ -59,13 +68,13 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
Must be unique and follow DNS naming conventions
</p>
</div>
</div>
<DialogFooter className="space-y-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant={!bucketName ? 'default_disabled' : 'default'}
variant="primary"
onClick={handleCreate}
disabled={!bucketName}
>
@@ -1,14 +1,17 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { FolderPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import { toast } from 'sonner';
interface CreateDirectoryDialogProps {
@@ -21,6 +24,8 @@ interface CreateDirectoryDialogProps {
export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreateDirectory }: CreateDirectoryDialogProps) {
const [dirName, setDirName] = useState('');
useEffect(() => { if (!open) setDirName(''); }, [open]);
const handleCreate = async () => {
if (!dirName) {
toast.error('Please enter a directory name');
@@ -38,15 +43,19 @@ export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreat
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Directory</DialogTitle>
<DialogDescription>
Create a new directory in {currentPath || 'the root'}
</DialogDescription>
<IconTile icon={<FolderPlus />} tone="primary" size="md" />
<div className="flex-1">
<DialogTitle>Create Directory</DialogTitle>
<DialogDescription>
Create a new directory in {currentPath || 'the root'}
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
<DialogBody className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Directory Name</label>
<Input
autoFocus
placeholder="my-directory"
value={dirName}
onChange={(e) => setDirName(e.target.value)}
@@ -57,9 +66,9 @@ export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreat
}}
/>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!dirName}>
@@ -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,3 +1,4 @@
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -7,6 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import type { S3Object } from '@/types';
interface DeleteObjectDialogProps {
@@ -27,16 +29,19 @@ export function DeleteObjectDialog({ open, onOpenChange, object, onDeleteObject
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={onOpenChange} size="destructive">
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Object</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{object?.key}"? This action cannot be undone.
</DialogDescription>
<IconTile icon={<Trash2 />} tone="destructive" size="md" />
<div className="flex-1">
<DialogTitle>Delete Object</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{object?.key}"? This action cannot be undone.
</DialogDescription>
</div>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
@@ -2,7 +2,6 @@ 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';
@@ -170,10 +169,9 @@ export function ObjectBrowserView({
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">
<Button variant="secondary" 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>
@@ -229,7 +227,7 @@ export function ObjectBrowserView({
<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}>
<Button variant="secondary" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
@@ -1,21 +1,46 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { objectsApi } from '@/lib/api';
import type { ObjectMetadata } from '@/types';
import { Header } from '@/components/layout/header';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Download, Trash, Copy, File } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { IconTile } from '@/components/ui/icon-tile';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { formatBytes } from '@/lib/file-utils';
import { formatDate } from '@/lib/utils';
function CardSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)]">
<div className="border-b border-[var(--border)] px-5 py-3.5">
<h3 className="text-[14px] font-semibold tracking-[-0.01em]">{title}</h3>
</div>
{children}
</section>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 gap-1 px-5 py-3.5 sm:grid-cols-[200px_1fr] sm:gap-4">
<dt className="text-[12.5px] font-medium text-[var(--muted-foreground)]">{label}</dt>
<dd className="text-[13.5px] text-[var(--foreground)] break-words">{children}</dd>
</div>
);
}
export function ObjectDetailsView() {
const navigate = useNavigate();
const { bucketName, '*': encodedObjectKey } = useParams();
// Decode the object key from the URL
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (!bucketName || !objectKey) {
@@ -23,7 +48,6 @@ export function ObjectDetailsView() {
setIsLoading(false);
return;
}
const fetchMetadata = async () => {
try {
setIsLoading(true);
@@ -32,240 +56,184 @@ export function ObjectDetailsView() {
setMetadata(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load object metadata');
console.error('Failed to fetch object metadata:', err);
} finally {
setIsLoading(false);
}
};
fetchMetadata();
}, [bucketName, objectKey]);
const parentPath = objectKey?.split('/').slice(0, -1).join('/') ?? '';
const fileName = objectKey?.split('/').pop() || objectKey || '';
const backHref = `/buckets/${bucketName}/objects${parentPath ? `?prefix=${encodeURIComponent(parentPath + '/')}` : ''}`;
const pathSegments = parentPath ? parentPath.split('/').filter(Boolean) : [];
const copy = (text: string, label = 'Copied') => {
navigator.clipboard.writeText(text);
toast.success(label);
};
const handleDownload = async () => {
if (!bucketName || !objectKey) return;
try {
const blob = await objectsApi.get(bucketName, objectKey);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = objectKey.split('/').pop() || 'download';
a.download = fileName || 'download';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Download started');
} catch (err) {
console.error('Download failed:', err);
} catch {
toast.error('Download failed');
}
};
const handleDelete = async () => {
if (!bucketName || !objectKey) return;
if (!confirm(`Are you sure you want to delete "${objectKey}"?`)) {
return;
}
try {
setDeleting(true);
await objectsApi.delete(bucketName, objectKey);
toast.success('Object deleted successfully');
handleBackNavigation();
} catch (err) {
console.error('Delete failed:', err);
toast.success('Object deleted');
navigate(backHref);
} catch {
toast.error('Delete failed');
} finally {
setDeleting(false);
setDeleteOpen(false);
}
};
const handleBackNavigation = () => {
if (!bucketName) return;
// Navigate back to the bucket explorer with the appropriate prefix
// Extract the folder path from the object key (everything before the last /)
const folderPath = objectKey?.split('/').slice(0, -1).join('/') || '';
const prefix = folderPath ? `${folderPath}/` : '';
// Navigate to the bucket view with the correct prefix
navigate(`/buckets?bucket=${encodeURIComponent(bucketName)}${prefix ? `&prefix=${encodeURIComponent(prefix)}` : ''}`);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
});
};
if (isLoading) {
return (
<div>
<Header title="Object Details" />
<div className="p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading object details...</div>
</div>
</div>
<div className="flex h-64 items-center justify-center gap-2 text-[var(--muted-foreground)]">
<Loader2 className="h-4 w-4 animate-spin" /> Loading object details
</div>
);
}
if (error || !metadata) {
return (
<div>
<Header title="Object Details" />
<div className="p-4 sm:p-6">
<Button variant="outline" onClick={handleBackNavigation} className="mb-4">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center justify-center h-64">
<div className="text-red-500">{error || 'Object not found'}</div>
</div>
<div className="px-7 py-6">
<Button variant="secondary" onClick={() => navigate(backHref)} className="mb-4">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<div className="rounded-xl border border-[var(--danger-border)] bg-[var(--danger-soft)] px-5 py-4 text-[13.5px] text-[var(--destructive)]">
{error || 'Object not found'}
</div>
</div>
);
}
const fileName = objectKey?.split('/').pop() || objectKey || '';
const pathParts = objectKey?.split('/').filter(part => part) || [];
const parentPath = pathParts.slice(0, -1).join('/');
return (
<div>
<Header title={fileName} />
<div className="p-4 sm:p-6 space-y-6">
{/* Back Button and Actions */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBackNavigation}>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4" />
Download
</Button>
<Button
variant="outline"
className="border-red-500 text-red-500 hover:bg-red-500/5"
onClick={handleDelete}
>
<Trash className="h-4 w-4" />
Delete
</Button>
</div>
</div>
{/* File Name Header */}
<div className="flex items-start gap-3 p-4 border-b border-border bg-card rounded-t-lg">
<div className="mt-1">
<File className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-4 flex-wrap">
<h2 className="text-lg font-medium text-foreground break-all">
{parentPath && (
<span className="text-muted-foreground font-mono">/{parentPath}/</span>
)}
{fileName}
</h2>
<button
onClick={() => copyToClipboard(metadata.key)}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0"
>
<Copy className="h-3 w-3" />
Copy
</button>
</div>
</div>
</div>
{/* Object Details Section */}
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Object Details</h3>
</div>
<div className="divide-y divide-border">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Date Created</div>
<div className="sm:col-span-2 text-sm text-foreground">
{formatDate(metadata.lastModified)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Type</div>
<div className="sm:col-span-2 text-sm text-foreground">
{metadata.contentType || 'application/octet-stream'}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Storage Class</div>
<div className="sm:col-span-2 text-sm text-foreground">
{metadata.storageClass || 'Standard'}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Size</div>
<div className="sm:col-span-2 text-sm text-foreground">
{formatBytes(metadata.size)}
</div>
</div>
</div>
</div>
{/* Custom Metadata Section */}
{metadata.metadata && Object.keys(metadata.metadata).length > 0 && (
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Custom Metadata</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/30">
<tr className="border-b border-border">
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
Key
</th>
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
Value
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{Object.entries(metadata.metadata).map(([key, value]) => (
<tr key={key} className="hover:bg-muted/30">
<td className="px-6 py-4 text-sm font-medium text-foreground break-all">
{key}
</td>
<td className="px-6 py-4 text-sm text-foreground break-all">{value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Object Preview Section */}
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Object Preview</h3>
</div>
<div className="p-6">
<p className="text-sm text-muted-foreground">No preview available</p>
</div>
</div>
<div className="px-7 py-6 space-y-6">
{/* Back + breadcrumb */}
<div className="flex items-center gap-2 text-[13px] text-[var(--muted-foreground)]">
<Link
to={backHref}
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 hover:bg-[var(--accent)] hover:text-[var(--foreground)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
Objects
</Link>
{pathSegments.map((seg, i) => (
<span key={i} className="inline-flex items-center gap-1">
<ChevronRight className="h-3.5 w-3.5 opacity-50" />
<span className="font-mono">{seg}</span>
</span>
))}
<ChevronRight className="h-3.5 w-3.5 opacity-50" />
<span className="truncate font-mono text-[var(--foreground)]">{fileName}</span>
</div>
{/* Hero */}
<section className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<IconTile icon={<File />} tone="primary" size="lg" />
<div className="min-w-0">
<h1 className="truncate text-[22px] font-semibold tracking-[-0.02em]">{fileName}</h1>
<button
type="button"
onClick={() => copy(metadata.key, 'Object key copied')}
title="Copy key"
className="group mt-1 inline-flex max-w-full items-center gap-1.5 truncate font-mono text-[13px] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<span className="truncate">{metadata.key}</span>
<Copy className="h-3 w-3 flex-shrink-0 opacity-60 group-hover:opacity-100" />
</button>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge>{formatBytes(metadata.size)}</Badge>
<Badge>{metadata.contentType || 'application/octet-stream'}</Badge>
{metadata.storageClass && <Badge>{metadata.storageClass}</Badge>}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="secondary" onClick={handleDownload}>
<Download className="h-4 w-4" /> Download
</Button>
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="h-4 w-4" /> Delete
</Button>
</div>
</section>
{/* Details */}
<CardSection title="Details">
<dl className="divide-y divide-[var(--border)]">
<DetailRow label="Size">{formatBytes(metadata.size)}</DetailRow>
<DetailRow label="Content type">{metadata.contentType || 'application/octet-stream'}</DetailRow>
<DetailRow label="Storage class">{metadata.storageClass || 'Standard'}</DetailRow>
<DetailRow label="Last modified">{formatDate(metadata.lastModified)}</DetailRow>
<DetailRow label="ETag">
<button
type="button"
onClick={() => copy(metadata.etag, 'ETag copied')}
className="inline-flex max-w-full items-center gap-1.5 truncate rounded-md bg-[var(--surface-sunken)] px-2 py-0.5 font-mono text-[12.5px] hover:bg-[var(--accent)]"
>
<span className="truncate">{metadata.etag}</span>
<Copy className="h-3 w-3 flex-shrink-0 opacity-60" />
</button>
</DetailRow>
{metadata.versionId && (
<DetailRow label="Version ID">
<span className="font-mono text-[12.5px]">{metadata.versionId}</span>
</DetailRow>
)}
</dl>
</CardSection>
{/* Custom metadata */}
{metadata.metadata && Object.keys(metadata.metadata).length > 0 && (
<CardSection title="Custom metadata">
<dl className="divide-y divide-[var(--border)]">
{Object.entries(metadata.metadata).map(([key, value]) => (
<DetailRow key={key} label={key}>
<span className="font-mono text-[12.5px]">{value}</span>
</DetailRow>
))}
</dl>
</CardSection>
)}
{/* Preview */}
<CardSection title="Preview">
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
No preview available for this object.
</div>
</CardSection>
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={`Delete "${fileName}"?`}
description="Applications referencing this object will no longer be able to read it."
confirmLabel="Delete object"
loading={deleting}
onConfirm={handleDelete}
/>
</div>
);
}
@@ -282,7 +282,7 @@ export function ObjectsTable({
</TableCell>
<TableCell className="hidden md:table-cell">
{obj.storageClass && (
<Badge variant="secondary">{obj.storageClass}</Badge>
<Badge variant="neutral">{obj.storageClass}</Badge>
)}
</TableCell>
<TableCell>{obj.isFolder ? null : formatBytes(obj.size)}</TableCell>
@@ -400,7 +400,7 @@ export function ObjectsTable({
<div className="flex items-center gap-2">
<Button
variant={hasPrevious ? "default": "default_disabled"}
variant="primary"
size="sm"
onClick={handlePreviousPage}
disabled={!hasPrevious}
@@ -411,7 +411,7 @@ export function ObjectsTable({
</Button>
<Button
variant={hasNext ? "default": "default_disabled"}
variant="primary"
size="sm"
onClick={handleNextPage}
disabled={!hasNext}
-2
View File
@@ -2,7 +2,5 @@ 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';
@@ -0,0 +1,105 @@
import { NavLink, Outlet, useParams } from 'react-router-dom';
import { Database, Copy, Upload } from 'lucide-react';
import { IconTile } from '@/components/ui/icon-tile';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { useBuckets } from '@/hooks/useApi';
import { toast } from 'sonner';
interface TabSpec {
to: string;
label: string;
end?: boolean;
}
const tabs: TabSpec[] = [
{ to: 'objects', label: 'Objects' },
{ to: 'permissions', label: 'Permissions' },
{ to: 'website', label: 'Website' },
{ to: 'settings', label: 'Settings' },
];
function formatBytes(n?: number) {
if (n == null) return '';
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n / 1024;
for (const u of units) {
if (v < 1024) return `${v.toFixed(v >= 10 ? 0 : 1)} ${u}`;
v /= 1024;
}
return `${v.toFixed(0)} PB`;
}
export function BucketDetailShell() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const { data: buckets = [] } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const s3Url = `s3://${bucketName}`;
const copyUrl = async () => {
try {
await navigator.clipboard.writeText(s3Url);
toast.success('URL copied');
} catch {
toast.error('Failed to copy');
}
};
return (
<div className="flex flex-col">
{/* Hero */}
<section className="px-7 pt-6 pb-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<IconTile icon={<Database />} tone="primary" size="lg" />
<div className="min-w-0">
<h1 className="truncate text-[26px] font-semibold tracking-[-0.02em]">{bucketName}</h1>
<p className="mt-1 truncate font-mono text-[13.5px] text-[var(--muted-foreground)]">{s3Url}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge variant="success">Active</Badge>
{bucket?.objectCount != null && <Badge>{bucket.objectCount.toLocaleString()} objects</Badge>}
{bucket?.size != null && <Badge>{formatBytes(bucket.size)}</Badge>}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="secondary" onClick={copyUrl}>
<Copy /> Copy URL
</Button>
<Button variant="primary" onClick={() => document.dispatchEvent(new CustomEvent('bucket:upload'))}>
<Upload /> Upload
</Button>
</div>
</div>
</section>
{/* Tabs */}
<nav className="flex h-12 items-center gap-0 border-b border-[var(--border)] px-7">
{tabs.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.end}
className={({ isActive }) =>
cn(
'relative -mb-px inline-flex h-12 items-center px-3.5 text-[14px] font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] rounded-sm',
isActive
? 'text-[var(--primary)] border-b-2 border-[var(--primary)]'
: 'text-[var(--muted-foreground)] border-b-2 border-transparent hover:text-[var(--foreground)]',
)
}
>
{t.label}
</NavLink>
))}
</nav>
<div className="min-w-0">
<Outlet />
</div>
</div>
);
}
+42 -11
View File
@@ -1,36 +1,67 @@
import { Outlet } from 'react-router-dom';
import { Outlet, useLocation, useParams } from 'react-router-dom';
import { Sidebar } from './sidebar';
import { useState } from 'react';
import { TopBar } from './top-bar';
import { useState, useMemo } from 'react';
import { Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { BreadcrumbItem } from '@/components/ui/breadcrumb';
function useCrumbs(): BreadcrumbItem[] {
const location = useLocation();
const params = useParams();
return useMemo(() => {
const path = location.pathname;
if (path === '/') return [{ label: 'Dashboard' }];
if (path === '/cluster') return [{ label: 'Cluster' }];
if (path === '/access') return [{ label: 'Access Control' }];
if (path === '/buckets') return [{ label: 'Buckets' }];
if (path.startsWith('/buckets/')) {
const bucketName = (params as { bucketName?: string }).bucketName ?? path.split('/')[2];
const crumbs: BreadcrumbItem[] = [
{ label: 'Buckets', to: '/buckets' },
{ label: bucketName, to: `/buckets/${bucketName}/objects` },
];
const segs = path.split('/').slice(3); // after /buckets/:name
if (segs[0] && segs[0] !== 'objects') {
const tabLabel = segs[0][0].toUpperCase() + segs[0].slice(1);
crumbs.push({ label: tabLabel });
}
return crumbs;
}
return [];
}, [location.pathname, params]);
}
export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const crumbs = useCrumbs();
return (
<div className="flex h-screen overflow-hidden">
{/* Mobile menu button */}
<div className="flex h-screen overflow-hidden bg-[var(--background)]">
<Button
variant="ghost"
size="icon"
className="fixed top-4 left-4 z-50 md:hidden"
className="fixed left-3 top-3 z-50 md:hidden"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle navigation"
>
<Menu className="h-6 w-6" />
<Menu className="h-5 w-5" />
</Button>
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
<div className="flex min-w-0 flex-1 flex-col">
<TopBar crumbs={crumbs} />
<main className="flex-1 overflow-y-auto scrollbar-thin">
<Outlet />
</main>
</div>
</div>
);
}
+84 -90
View File
@@ -1,8 +1,7 @@
import {Link, useLocation} from 'react-router-dom';
import {cn} from '@/lib/utils';
import {Database, Key, LayoutDashboard, LogOut, Server, User} from 'lucide-react';
import {useAuthStore} from '@/store/auth-store';
import {Button} from '@/components/ui/button';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { BookOpen, Database, Key, LayoutDashboard, Server } from 'lucide-react';
import { useAuthStore } from '@/store/auth-store';
import { useQuery } from '@tanstack/react-query';
import { healthApi, garageApi } from '@/lib/api';
@@ -12,26 +11,25 @@ interface NavItem {
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
interface NavGroup {
label?: string;
items: NavItem[];
}
const navGroups: NavGroup[] = [
{
title: 'Dashboard',
href: '/',
icon: LayoutDashboard,
items: [{ title: 'Dashboard', href: '/', icon: LayoutDashboard }],
},
{
title: 'Buckets',
href: '/buckets',
icon: Database,
label: 'Storage',
items: [{ title: 'Buckets', href: '/buckets', icon: Database }],
},
{
title: 'Cluster',
href: '/cluster',
icon: Server,
},
{
title: 'Access Control',
href: '/access',
icon: Key,
label: 'Cluster',
items: [
{ title: 'Cluster', href: '/cluster', icon: Server },
{ title: 'Access Control', href: '/access', icon: Key },
],
},
];
@@ -42,11 +40,7 @@ interface SidebarProps {
export function Sidebar({ isOpen, onClose }: SidebarProps) {
const location = useLocation();
const { user, config, logout } = useAuthStore();
const handleLogout = () => {
logout();
};
const { config } = useAuthStore();
const { data: uiVersion } = useQuery({
queryKey: ['ui-version'],
@@ -60,80 +54,80 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
queryFn: () => garageApi.getNodeInfo('self'),
staleTime: 5 * 60 * 1000,
retry: false,
enabled: !!(config && (config.admin.enabled || config.oidc.enabled)),
});
const garageVersion = nodeInfo
? Object.values(nodeInfo.success)[0]?.garageVersion
: undefined;
const garageVersion = nodeInfo ? Object.values(nodeInfo.success)[0]?.garageVersion : undefined;
const isActive = (href: string) =>
href === '/'
? location.pathname === '/'
: location.pathname === href || location.pathname.startsWith(href + '/');
return (
<div
<aside
className={cn(
'flex h-full w-64 flex-col border-r transition-transform duration-300 ease-in-out md:translate-x-0',
'flex h-full w-64 flex-col border-r border-[var(--border)] bg-[var(--background)] transition-transform duration-300 ease-in-out md:translate-x-0',
'fixed md:static z-50',
isOpen ? 'translate-x-0' : '-translate-x-full'
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 className="flex h-16 items-center gap-2 border-b border-[var(--border)] px-4">
<img src="/garage.png" alt="" className="h-8 w-8" />
<span className="text-[18px] font-semibold tracking-tight">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>
{config && (config.admin.enabled || config.oidc.enabled) && user && (
<div className="border-t p-4 space-y-2">
<div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
<User className="h-4 w-4" />
</div>
<div className="flex-1 overflow-hidden">
<p className="text-sm font-medium truncate">{user.name || user.username}</p>
{user.email && (
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
)}
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-5 scrollbar-thin">
{navGroups.map((group, gi) => (
<div key={gi}>
{group.label && (
<div className="px-2 pb-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
{group.label}
</div>
)}
<ul className="space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<li key={item.href}>
<Link
to={item.href}
onClick={onClose}
className={cn(
'flex h-9 items-center gap-2 rounded-md px-2.5 text-[14px] transition-colors',
active
? 'bg-[var(--primary)] font-medium text-[var(--primary-foreground)]'
: 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
)}
>
<Icon className="h-4 w-4" />
{item.title}
</Link>
</li>
);
})}
</ul>
</div>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={handleLogout}
>
<LogOut className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
)}
{(uiVersion || garageVersion) && (
<div className="px-4 pb-3 text-xs text-muted-foreground text-center">
{uiVersion && `UI ${uiVersion}`}
{uiVersion && garageVersion && ' | '}
{garageVersion && `Garage ${garageVersion}`}
</div>
)}
</div>
))}
</nav>
<div className="px-3 py-3 flex flex-col items-center gap-1.5">
<a
href="https://garagehq.deuxfleurs.fr/documentation/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[12.5px] text-[var(--muted-foreground)] transition-colors hover:bg-[var(--accent)] hover:text-[var(--foreground)]"
>
<BookOpen className="h-3.5 w-3.5" />
Documentation
</a>
{(uiVersion || garageVersion) && (
<div className="flex items-center gap-1.5 border-t border-[var(--border)] pt-2 w-full justify-center text-[12px] text-[var(--muted-foreground)]">
{uiVersion && <span>UI {uiVersion}</span>}
{uiVersion && garageVersion && <span className="opacity-40"></span>}
{garageVersion && <span>Garage {garageVersion}</span>}
</div>
)}
</div>
</aside>
);
}
@@ -14,7 +14,7 @@ export function ThemeToggle() {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="outline" size="icon">
<Button variant="secondary" 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>
@@ -0,0 +1,97 @@
import * as React from 'react';
import { User, LogOut, Monitor, Moon, Sun } from 'lucide-react';
import { Breadcrumb, type BreadcrumbItem } from '@/components/ui/breadcrumb';
import { useTheme } from '@/components/theme-provider';
import { useAuthStore } from '@/store/auth-store';
import { cn } from '@/lib/utils';
interface TopBarProps {
crumbs: BreadcrumbItem[];
}
export function TopBar({ crumbs }: TopBarProps) {
const { theme, setTheme } = useTheme();
const { user, config, logout } = useAuthStore();
const [menuOpen, setMenuOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!menuOpen) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [menuOpen]);
const hasUser = !!(config && (config.admin.enabled || config.oidc.enabled) && user);
return (
<div
className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-[var(--border)] bg-[var(--surface-sunken)] px-4 backdrop-blur"
>
<div className="min-w-0 flex-1 pl-8 md:pl-0">
<Breadcrumb items={crumbs} />
</div>
<div className="flex items-center gap-1">
<ThemeMiniToggle theme={theme} setTheme={setTheme} />
{hasUser && (
<div ref={menuRef} className="relative">
<button
type="button"
onClick={() => setMenuOpen((o) => !o)}
className="flex h-8 items-center gap-2 rounded-md px-2 text-[13.5px] text-[var(--foreground)] hover:bg-[var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--primary)] text-[var(--primary-foreground)]">
<User className="h-3.5 w-3.5" />
</span>
<span className="hidden max-w-[140px] truncate sm:inline">{user?.name || user?.username}</span>
</button>
{menuOpen && (
<div className="absolute right-0 mt-1 w-56 overflow-hidden rounded-md border border-[var(--border)] bg-[var(--popover)] shadow-lg">
<div className="border-b border-[var(--border)] px-3 py-2">
<div className="truncate text-[14px] font-medium">{user?.name || user?.username}</div>
{user?.email && (
<div className="truncate text-[12.5px] text-[var(--muted-foreground)]">{user.email}</div>
)}
</div>
<button
type="button"
onClick={() => { setMenuOpen(false); logout(); }}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[14px] hover:bg-[var(--accent)]"
>
<LogOut className="h-3.5 w-3.5" /> Logout
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
function ThemeMiniToggle({
theme,
setTheme,
}: {
theme: 'light' | 'dark' | 'system';
setTheme: (t: 'light' | 'dark' | 'system') => void;
}) {
const next = theme === 'dark' ? 'light' : theme === 'light' ? 'system' : 'dark';
const Icon = theme === 'dark' ? Moon : theme === 'light' ? Sun : Monitor;
return (
<button
type="button"
onClick={() => setTheme(next)}
aria-label={`Switch theme (current: ${theme})`}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--muted-foreground)]',
'hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
)}
>
<Icon className="h-4 w-4" />
</button>
);
}
+14 -12
View File
@@ -3,21 +3,23 @@ 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',
'inline-flex items-center rounded-md border px-2 py-0.5 text-[12px] font-medium tracking-tight',
{
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',
neutral:
'bg-[var(--card)] border-[var(--border)] text-[var(--muted-foreground)]',
success:
'bg-[var(--success-soft)] border-transparent text-[color:#2ca02c] dark:text-[color:#73bf69]',
warning:
'bg-[var(--accent-primary-soft)] border-[var(--accent-primary-border)] text-[var(--primary)]',
danger:
'bg-[var(--danger-soft)] border-[var(--danger-border)] text-[var(--destructive)]',
primary:
'bg-[var(--primary)] border-transparent text-[var(--primary-foreground)]',
},
},
defaultVariants: {
variant: 'default',
},
defaultVariants: { variant: 'neutral' },
}
);
@@ -25,8 +27,8 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
export function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
export { badgeVariants };
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface BreadcrumbItem {
label: string;
to?: string;
}
interface BreadcrumbProps extends React.HTMLAttributes<HTMLElement> {
items: BreadcrumbItem[];
}
export function Breadcrumb({ items, className, ...props }: BreadcrumbProps) {
return (
<nav
aria-label="Breadcrumb"
className={cn('flex items-center gap-1.5 text-[13.5px] text-[var(--muted-foreground)]', className)}
{...props}
>
{items.map((item, idx) => {
const isLast = idx === items.length - 1;
return (
<React.Fragment key={`${item.label}-${idx}`}>
{item.to && !isLast ? (
<Link
to={item.to}
className="rounded-sm px-1 text-[var(--foreground)]/80 hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
{item.label}
</Link>
) : (
<span className={cn('px-1', isLast && 'font-medium text-[var(--foreground)]')}>
{item.label}
</span>
)}
{!isLast && <ChevronRight className="h-3.5 w-3.5 text-[var(--muted-foreground)]/60" />}
</React.Fragment>
);
})}
</nav>
);
}
+28 -19
View File
@@ -3,28 +3,39 @@ 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',
[
'inline-flex items-center justify-center gap-2 whitespace-nowrap',
'rounded-md text-[14px] font-medium tracking-tight',
'transition-colors ring-offset-background',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:h-3.5 [&_svg]:w-3.5 [&_svg]:shrink-0',
].join(' '),
{
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',
primary:
'bg-[var(--primary)] text-[var(--primary-foreground)] font-semibold hover:brightness-[1.04] cursor-pointer',
secondary:
'bg-transparent border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--accent)] cursor-pointer',
ghost:
'bg-transparent text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)] cursor-pointer',
destructive:
'bg-[var(--destructive)] text-[var(--destructive-foreground)] font-semibold hover:brightness-[1.05] cursor-pointer',
link:
'text-[var(--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',
sm: 'h-8 px-3',
default: 'h-[38px] px-4',
lg: 'h-11 px-5',
'icon-sm': 'h-8 w-8 p-0',
icon: 'h-[38px] w-[38px] p-0',
'icon-lg': 'h-11 w-11 p-0',
},
},
defaultVariants: {
variant: 'default',
variant: 'primary',
size: 'default',
},
}
@@ -35,11 +46,9 @@ export interface ButtonProps
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} />
);
}
({ className, variant, size, ...props }, ref) => (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
);
Button.displayName = 'Button';
@@ -0,0 +1,69 @@
import * as React from 'react';
import { Trash2, AlertTriangle } from 'lucide-react';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './dialog';
import { IconTile } from './icon-tile';
import { Button } from './button';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
icon?: React.ReactNode;
tone?: 'destructive' | 'primary';
loading?: boolean;
onConfirm: () => void | Promise<void>;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
icon,
tone = 'destructive',
loading = false,
onConfirm,
}: ConfirmDialogProps) {
const defaultIcon = tone === 'destructive' ? <Trash2 /> : <AlertTriangle />;
return (
<Dialog open={open} onOpenChange={onOpenChange} size="destructive">
<DialogContent>
<DialogHeader>
<IconTile icon={icon ?? defaultIcon} tone={tone} size="md" />
<div className="flex-1">
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</div>
</DialogHeader>
<DialogBody>
<p className="text-[13.5px] text-[var(--muted-foreground)]">This action cannot be undone.</p>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={tone === 'destructive' ? 'destructive' : 'primary'}
onClick={() => onConfirm()}
disabled={loading}
>
{loading ? 'Working…' : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,91 @@
import * as React from 'react';
import { Trash2 } from 'lucide-react';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './dialog';
import { IconTile } from './icon-tile';
import { Button } from './button';
import { Input } from './input';
interface DangerousConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: React.ReactNode;
/** Exact string the user must type to enable the confirm button. */
confirmationText: string;
confirmLabel?: string;
cancelLabel?: string;
icon?: React.ReactNode;
loading?: boolean;
onConfirm: () => void | Promise<void>;
}
export function DangerousConfirmDialog({
open,
onOpenChange,
title,
description,
confirmationText,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
icon,
loading = false,
onConfirm,
}: DangerousConfirmDialogProps) {
const [value, setValue] = React.useState('');
React.useEffect(() => { if (!open) setValue(''); }, [open]);
const matches = value === confirmationText;
const submit = () => { if (matches && !loading) onConfirm(); };
return (
<Dialog open={open} onOpenChange={onOpenChange} size="destructive">
<DialogContent>
<DialogHeader>
<IconTile icon={icon ?? <Trash2 />} tone="destructive" size="md" />
<div className="flex-1">
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</div>
</DialogHeader>
<DialogBody className="space-y-3">
<p className="text-[13.5px] text-[var(--muted-foreground)]">
This action cannot be undone. To confirm, type{' '}
<code className="rounded bg-[var(--surface-sunken)] px-1 py-0.5 font-mono text-[13px] text-[var(--foreground)]">
{confirmationText}
</code>{' '}
below.
</p>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
placeholder={confirmationText}
aria-label={`Type ${confirmationText} to confirm`}
/>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant="destructive"
onClick={submit}
disabled={!matches || loading}
>
{loading ? 'Working…' : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+132 -79
View File
@@ -1,149 +1,201 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
type DialogSize = 'standard' | 'form' | 'destructive';
interface DialogContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
size: DialogSize;
}
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;
const ctx = React.useContext(DialogContext);
if (!ctx) throw new Error('useDialog must be used within a Dialog');
return ctx;
}
interface DialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
size?: DialogSize;
children: React.ReactNode;
}
const Dialog: React.FC<DialogProps> = ({ open = false, onOpenChange, children }) => {
return (
<DialogContext.Provider value={{ open, onOpenChange: onOpenChange || (() => {}) }}>
{children}
</DialogContext.Provider>
);
};
const Dialog: React.FC<DialogProps> = ({ open = false, onOpenChange, size = 'standard', children }) => (
<DialogContext.Provider value={{ open, onOpenChange: onOpenChange || (() => {}), size }}>
{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 DialogTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ onClick, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<div
<button
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)}
onClick={(e) => { onOpenChange(true); onClick?.(e); }}
{...props}
/>
);
}
);
DialogOverlay.displayName = 'DialogOverlay';
DialogTrigger.displayName = 'DialogTrigger';
const widthClass: Record<DialogSize, string> = {
standard: 'max-w-[480px]',
form: 'max-w-[600px]',
destructive: 'max-w-[440px]',
};
const DialogOverlay: React.FC = () => {
const { onOpenChange } = useDialog();
return (
<div
className="fixed inset-0 z-50 bg-black/55 backdrop-blur-[8px]"
onClick={() => onOpenChange(false)}
/>
);
};
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<DialogPortal>
const { open, onOpenChange, size } = useDialog();
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onOpenChange(false);
return;
}
if (e.key !== 'Tab' || !containerRef.current) return;
const focusables = containerRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', keyHandler);
setTimeout(() => {
const first = containerRef.current?.querySelector<HTMLElement>(
'input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
first?.focus();
}, 0);
return () => {
document.removeEventListener('keydown', keyHandler);
previouslyFocused?.focus?.();
};
}, [open, onOpenChange]);
if (!open) return null;
return createPortal(
<>
<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={containerRef}
role="dialog"
aria-modal="true"
className={cn(
'fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2',
widthClass[size],
)}
>
<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
'relative overflow-hidden rounded-xl border border-[var(--border)]',
'bg-[var(--card)] text-[var(--card-foreground)]',
'shadow-[0_20px_40px_rgba(0,0,0,0.3)]',
className,
)}
{...props}
>
{children}
<button
type="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"
aria-label="Close"
className="absolute right-3 top-3 inline-flex h-7 w-7 items-center justify-center rounded-md text-[var(--muted-foreground)] hover:bg-[var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
</div>
</DialogPortal>
</>,
document.body,
);
}
);
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} />;
const DialogHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(
'flex items-start gap-3 border-b border-[var(--border)] px-6 py-5',
className,
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
const DialogBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div className={cn('px-6 py-5', className)} {...props} />
);
DialogBody.displayName = 'DialogBody';
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)}
className={cn(
'flex justify-end gap-2 border-t border-[var(--border)] bg-[var(--surface-sunken)] px-6 py-3.5',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
const DialogTitleText = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
className={cn('text-[20px] font-semibold tracking-[-0.015em] leading-tight', className)}
{...props}
/>
)
);
DialogTitle.displayName = 'DialogTitle';
DialogTitleText.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} />
));
const DialogDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('mt-1 text-[13.5px] leading-[1.45] text-[var(--muted-foreground)]', className)}
{...props}
/>
)
);
DialogDescription.displayName = 'DialogDescription';
export {
@@ -151,7 +203,8 @@ export {
DialogTrigger,
DialogContent,
DialogHeader,
DialogBody,
DialogFooter,
DialogTitle,
DialogTitleText as DialogTitle,
DialogDescription,
};
@@ -0,0 +1,32 @@
import * as React from 'react';
import { IconTile } from './icon-tile';
import { cn } from '@/lib/utils';
interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
icon: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
tone?: 'primary' | 'neutral' | 'destructive';
}
export function EmptyState({ icon, title, description, action, tone = 'primary', className, ...props }: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-xl border border-[var(--border)] bg-[var(--card)] px-6 py-12 text-center',
className,
)}
{...props}
>
<IconTile icon={icon} tone={tone} size="lg" />
<div className="space-y-1">
<h3 className="text-[17px] font-semibold tracking-tight">{title}</h3>
{description && (
<p className="max-w-sm text-[13.5px] text-[var(--muted-foreground)]">{description}</p>
)}
</div>
{action && <div className="pt-1">{action}</div>}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
const iconTileVariants = cva(
'inline-flex items-center justify-center shrink-0 border',
{
variants: {
tone: {
primary: 'bg-[var(--accent-primary-soft)] border-[var(--accent-primary-border)] text-[var(--primary)]',
destructive: 'bg-[var(--danger-soft)] border-[var(--danger-border)] text-[var(--destructive)]',
neutral: 'bg-muted border-border text-muted-foreground',
},
size: {
sm: 'h-8 w-8 rounded-md [&>svg]:h-4 [&>svg]:w-4',
md: 'h-10 w-10 rounded-lg [&>svg]:h-5 [&>svg]:w-5',
lg: 'h-14 w-14 rounded-xl [&>svg]:h-7 [&>svg]:w-7',
},
},
defaultVariants: { tone: 'primary', size: 'md' },
}
);
export interface IconTileProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof iconTileVariants> {
icon: React.ReactNode;
}
export function IconTile({ icon, tone, size, className, ...props }: IconTileProps) {
return (
<div className={cn(iconTileVariants({ tone, size }), className)} {...props}>
{icon}
</div>
);
}
+16 -14
View File
@@ -1,22 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export type InputProps = 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}
/>
);
}
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
'flex h-[38px] w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-1 text-[15px]',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-[var(--muted-foreground)]',
'focus-visible:outline-none focus-visible:border-[var(--primary)] focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
)
);
Input.displayName = 'Input';
@@ -0,0 +1,28 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
title: string;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
}
export function PageHeader({ title, subtitle, actions, className, ...props }: PageHeaderProps) {
return (
<div
className={cn(
'flex flex-col gap-4 border-b border-[var(--border)] px-6 py-5 sm:flex-row sm:items-start sm:justify-between sm:gap-6',
className,
)}
{...props}
>
<div className="min-w-0">
<h1 className="text-[26px] font-semibold tracking-[-0.02em] leading-tight truncate">{title}</h1>
{subtitle && (
<div className="mt-1 text-[13.5px] text-[var(--muted-foreground)]">{subtitle}</div>
)}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
);
}
+6 -5
View File
@@ -53,7 +53,7 @@ const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivEl
<div
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
'flex h-12 items-center gap-0 border-b border-[var(--border)] text-[14px]',
className
)}
{...props}
@@ -70,15 +70,16 @@ 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',
'relative h-12 px-3.5 -mb-px inline-flex items-center justify-center',
'text-[14px] font-medium transition-colors cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] rounded-sm',
isActive
? 'bg-background text-foreground shadow-sm'
: 'hover:bg-accent hover:text-accent-foreground',
? 'text-[var(--primary)] border-b-2 border-[var(--primary)]'
: 'text-[var(--muted-foreground)] border-b-2 border-transparent hover:text-[var(--foreground)]',
className
)}
onClick={(e) => {
+7 -7
View File
@@ -90,10 +90,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
setUploadTasks(tasks);
let successCount = 0;
let errorCount = 0;
for (const task of tasks) {
const results = await Promise.all(tasks.map(async (task) => {
try {
setUploadTasks(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'uploading' as const } : t
@@ -109,16 +106,19 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
setUploadTasks(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
));
successCount++;
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
setUploadTasks(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t
));
errorCount++;
console.error(`Failed to upload ${task.key}:`, error);
return false;
}
}
}));
const successCount = results.filter(Boolean).length;
const errorCount = results.length - successCount;
if (errorCount === 0) {
if (hasRelativePaths && folders.size > 0) {
+81 -22
View File
@@ -1,30 +1,71 @@
@import 'tailwindcss';
@font-face {
font-family: 'Geist Sans';
src: url('/fonts/geist-sans-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Sans';
src: url('/fonts/geist-sans-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Sans';
src: url('/fonts/geist-sans-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('/fonts/geist-mono-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('/fonts/geist-mono-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@layer base {
:root {
/* Light Theme Colors */
--background: #ffffff;
--foreground: #0a0a0f;
--card: #ffffff;
--card-foreground: #0a0a0f;
--popover: #fafafa;
--popover-foreground: #0a0a0f;
--background: #faf7f2;
--foreground: #1c1917;
--card: #fffdf9;
--card-foreground: #1c1917;
--popover: #fffdf9;
--popover-foreground: #1c1917;
--primary: #ff9447;
--primary-foreground: #ffffff;
--secondary: #f4f4f5;
--secondary-foreground: #18181b;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #18181b;
--secondary: #f3efe8;
--secondary-foreground: #1c1917;
--muted: #f3efe8;
--muted-foreground: #78716c;
--accent: #f3efe8;
--accent-foreground: #1c1917;
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--border: #e4e4e7;
--input: #e4e4e7;
--destructive-foreground: #ffffff;
--border: #e7e2d8;
--input: #e7e2d8;
--ring: #ff9447;
--radius: 0.5rem;
/* Grafana Chart Colors */
--accent-primary-soft: rgba(255, 148, 71, 0.10);
--accent-primary-border: rgba(255, 148, 71, 0.22);
--danger-soft: rgba(239, 68, 68, 0.10);
--danger-border: rgba(239, 68, 68, 0.28);
--success-soft: rgba(34, 197, 94, 0.10);
--surface-sunken: rgba(120, 90, 40, 0.05);
--chart-blue: #1f77b4;
--chart-orange: #ff7f0e;
--chart-green: #2ca02c;
@@ -38,7 +79,6 @@
}
.dark {
/* Dark Theme Colors - VS Code Inspired */
--background: #1a1d29;
--foreground: #e8eaed;
--card: #252834;
@@ -46,7 +86,7 @@
--popover: #2d3142;
--popover-foreground: #e8eaed;
--primary: #ff9447;
--primary-foreground: #ffffff;
--primary-foreground: #1a1d29;
--secondary: #3a3f52;
--secondary-foreground: #e8eaed;
--muted: #4a5064;
@@ -54,12 +94,18 @@
--accent: #3a3f52;
--accent-foreground: #e8eaed;
--destructive: #ef5350;
--destructive-foreground: #e8eaed;
--destructive-foreground: #1a1d29;
--border: #3a3f52;
--input: #3a3f52;
--ring: #ff9447;
/* Grafana Chart Colors */
--accent-primary-soft: rgba(255, 148, 71, 0.12);
--accent-primary-border: rgba(255, 148, 71, 0.22);
--danger-soft: rgba(239, 83, 80, 0.14);
--danger-border: rgba(239, 83, 80, 0.32);
--success-soft: rgba(115, 191, 105, 0.10);
--surface-sunken: rgba(0, 0, 0, 0.18);
--chart-blue: #3eb0ff;
--chart-orange: #ff9830;
--chart-green: #73bf69;
@@ -76,10 +122,23 @@
border-color: var(--border);
}
html {
font-family: 'Geist Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
background-color: var(--background);
color: var(--foreground);
font-feature-settings: 'rlig' 1, 'calt' 1;
font-feature-settings: 'rlig' 1, 'calt' 1, 'ss01' 1;
}
code, pre, kbd, samp, .font-mono {
font-family: 'Geist Mono', ui-monospace, 'SF Mono', 'Menlo', Consolas, monospace;
}
}
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
import { useBucketObjects } from '@/hooks/useBucketObjects';
export function BucketObjects() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [currentPath, setCurrentPath] = useState(searchParams.get('prefix') ?? '');
const [searchQuery, setSearchQuery] = useState('');
const [initialPageToken, setInitialPageToken] = useState<string | undefined>(
searchParams.get('page') ?? undefined,
);
const [initialItemsPerPage, setInitialItemsPerPage] = useState<number>(
parseInt(searchParams.get('limit') ?? '25', 10),
);
useEffect(() => {
const prefix = searchParams.get('prefix') ?? '';
if (prefix !== currentPath) setCurrentPath(prefix);
setInitialPageToken(searchParams.get('page') ?? undefined);
setInitialItemsPerPage(parseInt(searchParams.get('limit') ?? '25', 10));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
const {
objects,
isLoading,
isRefreshing,
isNavigating,
isTruncated,
nextContinuationToken,
itemsPerPage,
setItemsPerPage,
uploadFiles,
uploadTasks,
deleteObject,
deleteMultipleObjects,
createDirectory,
fetchObjects,
} = useBucketObjects(bucketName, currentPath);
const handleNavigateToFolder = (path: string) => {
setCurrentPath(path);
const next = new URLSearchParams();
if (path) next.set('prefix', path);
setSearchParams(next);
};
const handlePageChange = (token?: string) => {
fetchObjects(token);
const next = new URLSearchParams();
if (currentPath) next.set('prefix', currentPath);
if (token) next.set('page', token);
if (itemsPerPage !== 25) next.set('limit', String(itemsPerPage));
setSearchParams(next);
};
const handleItemsPerPageChange = (count: number) => {
setItemsPerPage(count);
const next = new URLSearchParams();
if (currentPath) next.set('prefix', currentPath);
if (count !== 25) next.set('limit', String(count));
setSearchParams(next);
};
const handleRefresh = async () => {
await fetchObjects(undefined, true);
};
const handleBackToBuckets = () => navigate('/buckets');
// CustomEvent bridge for the Upload button in BucketDetailShell hero.
const uploadInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handler = () => uploadInputRef.current?.click();
document.addEventListener('bucket:upload', handler);
return () => document.removeEventListener('bucket:upload', handler);
}, []);
return (
<>
<input
ref={uploadInputRef}
type="file"
multiple
hidden
onChange={async (e) => {
const files = Array.from(e.target.files ?? []);
if (files.length > 0) await uploadFiles(files);
e.target.value = '';
}}
/>
<ObjectBrowserView
bucketName={bucketName}
objects={objects}
currentPath={currentPath}
searchQuery={searchQuery}
isLoading={isLoading}
isTruncated={isTruncated}
nextContinuationToken={nextContinuationToken}
itemsPerPage={itemsPerPage}
onSearchChange={setSearchQuery}
onNavigateToFolder={handleNavigateToFolder}
onBackToBuckets={handleBackToBuckets}
onUploadFiles={uploadFiles}
uploadTasks={uploadTasks}
onDeleteObject={deleteObject}
onDeleteMultipleObjects={deleteMultipleObjects}
onCreateDirectory={createDirectory}
onRefresh={handleRefresh}
onPageChange={handlePageChange}
onItemsPerPageChange={handleItemsPerPageChange}
isRefreshing={isRefreshing}
isNavigating={isNavigating}
initialPageToken={initialPageToken}
initialItemsPerPage={initialItemsPerPage}
/>
</>
);
}
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { KeyRound, ShieldCheck } from 'lucide-react';
import { useAccessKeys, useGrantBucketPermission } from '@/hooks/useApi';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectOption } from '@/components/ui/select';
import { EmptyState } from '@/components/ui/empty-state';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
export function BucketPermissions() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const { data: availableKeys = [] } = useAccessKeys();
const grant = useGrantBucketPermission();
const [selectedKey, setSelectedKey] = useState('');
const [read, setRead] = useState(false);
const [write, setWrite] = useState(false);
const [owner, setOwner] = useState(false);
useEffect(() => {
if (!selectedKey) {
setRead(false); setWrite(false); setOwner(false);
return;
}
const key = availableKeys.find((k) => k.accessKeyId === selectedKey);
const existing = key?.permissions.find(
(p) => p.bucketName === bucketName || p.bucketId === bucketName,
);
setRead(existing?.read ?? false);
setWrite(existing?.write ?? false);
setOwner(existing?.owner ?? false);
}, [selectedKey, availableKeys, bucketName]);
const canSubmit = !!selectedKey && (read || write || owner) && !grant.isPending;
const onGrant = async () => {
if (!selectedKey) { toast.error('Please select an access key'); return; }
if (!read && !write && !owner) { toast.error('Please select at least one permission'); return; }
try {
await grant.mutateAsync({
bucketName,
accessKeyId: selectedKey,
permissions: { read, write, owner },
});
toast.success('Permissions granted');
setSelectedKey(''); setRead(false); setWrite(false); setOwner(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Grant failed');
}
};
const granted = availableKeys
.map((k) => ({
key: k,
perm: k.permissions.find(
(p) => p.bucketName === bucketName || p.bucketId === bucketName,
),
}))
.filter((x) => !!x.perm);
return (
<div className="space-y-6 px-7 py-6">
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
<header className="flex items-center gap-2 border-b border-[var(--border)] px-5 py-3">
<ShieldCheck className="h-4 w-4 text-[var(--primary)]" />
<h2 className="text-[15px] font-semibold">Grant access</h2>
</header>
<div className="space-y-5 px-5 py-5">
<div className="space-y-1.5">
<label className="text-[13.5px] font-medium">Access key</label>
<Select value={selectedKey} onChange={(v) => setSelectedKey(v)}>
<SelectOption value="">-- Select an access key --</SelectOption>
{availableKeys.map((k) => (
<SelectOption key={k.accessKeyId} value={k.accessKeyId}>
{k.name} ({k.accessKeyId})
</SelectOption>
))}
</Select>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
Choose which access key should have permissions on this bucket. Current permissions pre-fill below.
</p>
</div>
<div className="space-y-1.5">
<div className="text-[13.5px] font-medium">Permissions</div>
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
<PermRow
id="perm-read"
checked={read}
onChange={setRead}
title="Read"
description="Allows reading objects from the bucket (GetObject, HeadObject, ListObjects)"
/>
<PermRow
id="perm-write"
checked={write}
onChange={setWrite}
title="Write"
description="Allows writing and deleting objects in the bucket (PutObject, DeleteObject)"
/>
<PermRow
id="perm-owner"
checked={owner}
onChange={setOwner}
title="Owner"
description="Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy)"
/>
</div>
</div>
<div className="pt-1">
<Button onClick={onGrant} disabled={!canSubmit}>
{grant.isPending ? 'Granting…' : 'Grant access'}
</Button>
</div>
</div>
</section>
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
<header className="flex items-center gap-2 border-b border-[var(--border)] px-5 py-3">
<KeyRound className="h-4 w-4 text-[var(--primary)]" />
<h2 className="text-[15px] font-semibold">Granted</h2>
</header>
{granted.length === 0 ? (
<div className="p-5">
<EmptyState
icon={<KeyRound />}
tone="neutral"
title="No access granted"
description="Grant at least one access key to make this bucket usable."
/>
</div>
) : (
<ul className="divide-y divide-[var(--border)]">
{granted.map(({ key, perm }) => (
<li key={key.accessKeyId} className="flex items-center gap-4 px-5 py-3">
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-medium">{key.name}</div>
<div className="truncate font-mono text-[12.5px] text-[var(--muted-foreground)]">
{key.accessKeyId}
</div>
</div>
<div className="flex items-center gap-1">
{perm!.read && <Badge variant="success">Read</Badge>}
{perm!.write && <Badge variant="warning">Write</Badge>}
{perm!.owner && <Badge variant="primary">Owner</Badge>}
</div>
</li>
))}
</ul>
)}
</section>
</div>
);
}
function PermRow({
id,
checked,
onChange,
title,
description,
}: {
id: string;
checked: boolean;
onChange: (v: boolean) => void;
title: string;
description: string;
}) {
return (
<div className="flex items-start gap-3">
<Checkbox id={id} checked={checked} onCheckedChange={(c) => onChange(c as boolean)} />
<div className="flex-1">
<label htmlFor={id} className="text-[14px] font-medium leading-none cursor-pointer">
{title}
</label>
<p className="mt-1 text-[12.5px] text-[var(--muted-foreground)]">{description}</p>
</div>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { AlertTriangle, Info } from 'lucide-react';
import { useBuckets, useDeleteBucket } from '@/hooks/useApi';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/ui/empty-state';
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
import { toast } from 'sonner';
import { formatBytes } from '@/lib/file-utils';
import { formatDate as formatDateUtil } from '@/lib/utils';
const formatBytesOrDash = (n?: number) => (n == null ? '—' : formatBytes(n));
const formatDateOrDash = (iso?: string) => (iso ? formatDateUtil(iso) : '—');
export function BucketSettings() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const navigate = useNavigate();
const { data: buckets = [], isLoading } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const deleteMutation = useDeleteBucket();
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
if (isLoading) {
return <div className="px-7 py-6 text-[13.5px] text-[var(--muted-foreground)]">Loading</div>;
}
if (!bucket) {
return (
<div className="px-7 py-6">
<EmptyState
icon={<AlertTriangle />}
tone="neutral"
title="Bucket not found"
description="The bucket you're looking for doesn't exist or you don't have access."
/>
</div>
);
}
const confirmDelete = async () => {
setDeleting(true);
try {
await deleteMutation.mutateAsync(bucket.name);
toast.success('Bucket deleted');
navigate('/buckets');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed');
setDeleting(false);
}
};
return (
<div className="space-y-6 px-7 py-6">
{/* Info */}
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
<header className="flex items-center gap-2 border-b border-[var(--border)] px-5 py-3">
<Info className="h-4 w-4 text-[var(--primary)]" />
<h2 className="text-[15px] font-semibold">Bucket info</h2>
</header>
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 px-5 py-5 sm:grid-cols-2">
<Field label="Name" value={<span className="font-mono text-[13.5px]">{bucket.name}</span>} />
<Field label="Region" value={bucket.region ?? '—'} />
<Field label="Created" value={formatDateOrDash(bucket.creationDate)} />
<Field label="Objects" value={bucket.objectCount != null ? bucket.objectCount.toLocaleString() : '—'} />
<Field label="Size" value={formatBytesOrDash(bucket.size)} />
<Field
label="Website"
value={
<Badge variant={bucket.websiteAccess ? 'success' : 'neutral'}>
{bucket.websiteAccess ? 'Enabled' : 'Disabled'}
</Badge>
}
/>
</dl>
</section>
{/* Danger zone */}
<section className="rounded-xl border border-[var(--danger-border)] bg-[var(--card)]">
<header className="border-b border-[var(--danger-border)] px-5 py-3">
<h2 className="text-[15px] font-semibold text-[var(--destructive)]">Danger zone</h2>
<p className="mt-0.5 text-[13.5px] text-[var(--muted-foreground)]">
Destructive actions for this bucket.
</p>
</header>
<div className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<div className="text-[14px] font-medium">Delete bucket</div>
<div className="text-[13.5px] text-[var(--muted-foreground)]">
All objects in this bucket will be permanently removed.
</div>
</div>
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
Delete bucket
</Button>
</div>
</section>
<DangerousConfirmDialog
open={deleteOpen}
onOpenChange={(o) => {
if (!o && !deleting) setDeleteOpen(false);
}}
title={`Delete bucket "${bucket.name}"?`}
description="This action cannot be undone."
confirmationText={bucket.name}
confirmLabel="Delete bucket"
loading={deleting}
onConfirm={confirmDelete}
/>
</div>
);
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt className="text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">{label}</dt>
<dd className="mt-1 text-[14px]">{value}</dd>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Globe } from 'lucide-react';
import { useBuckets } from '@/hooks/useApi';
import { bucketsApi } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/ui/empty-state';
import { toast } from 'sonner';
export function BucketWebsite() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const queryClient = useQueryClient();
const { data: buckets = [], isLoading } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const [enabled, setEnabled] = useState(false);
const [indexDocument, setIndexDocument] = useState('index.html');
const [errorDocument, setErrorDocument] = useState('');
const [saving, setSaving] = useState(false);
// Sync local form state whenever the underlying bucket changes.
useEffect(() => {
if (!bucket) return;
setEnabled(bucket.websiteAccess);
setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html');
setErrorDocument(bucket.websiteConfig?.errorDocument ?? '');
}, [bucket?.name, bucket?.websiteAccess, bucket?.websiteConfig?.indexDocument, bucket?.websiteConfig?.errorDocument]);
if (isLoading) {
return <div className="px-7 py-6 text-[13.5px] text-[var(--muted-foreground)]">Loading</div>;
}
if (!bucket) {
return (
<div className="px-7 py-6">
<EmptyState
icon={<AlertTriangle />}
tone="neutral"
title="Bucket not found"
description="The bucket you're looking for doesn't exist or you don't have access."
/>
</div>
);
}
const wasEnabled = bucket.websiteAccess;
const disabling = wasEnabled && !enabled;
const handleReset = () => {
setEnabled(bucket.websiteAccess);
setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html');
setErrorDocument(bucket.websiteConfig?.errorDocument ?? '');
};
const handleSave = async () => {
setSaving(true);
try {
await bucketsApi.updateBucketWebsite(bucketName, {
enabled,
indexDocument: enabled ? indexDocument : undefined,
errorDocument: enabled && errorDocument ? errorDocument : undefined,
});
await queryClient.invalidateQueries({ queryKey: ['buckets'] });
toast.success(disabling ? 'Website disabled' : 'Website configuration updated');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Save failed');
} finally {
setSaving(false);
}
};
const saveDisabled = saving || (enabled && !indexDocument);
return (
<div className="px-7 py-6">
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
<header className="flex items-center gap-2 border-b border-[var(--border)] px-5 py-3">
<Globe className="h-4 w-4 text-[var(--primary)]" />
<h2 className="text-[15px] font-semibold">Static website hosting</h2>
</header>
<div className="space-y-6 px-5 py-5">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-[14px] font-medium">Website access</p>
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
Allow public HTTP access to bucket objects
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={enabled ? 'primary' : 'neutral'}>
{enabled ? 'Enabled' : 'Disabled'}
</Badge>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
{enabled && (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-[13.5px] font-medium">
Index document <span className="text-[var(--destructive)]">*</span>
</label>
<Input
value={indexDocument}
onChange={(e) => setIndexDocument(e.target.value)}
placeholder="index.html"
/>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
The file served when a directory is requested (e.g. index.html)
</p>
</div>
<div className="space-y-2">
<label className="text-[13.5px] font-medium">Error document</label>
<Input
value={errorDocument}
onChange={(e) => setErrorDocument(e.target.value)}
placeholder="404.html (optional)"
/>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
The file served when an object is not found (optional)
</p>
</div>
</div>
)}
</div>
<footer className="flex justify-end gap-2 border-t border-[var(--border)] bg-[var(--surface-sunken)] px-5 py-3">
<Button variant="secondary" onClick={handleReset} disabled={saving}>Reset</Button>
<Button
onClick={handleSave}
variant={disabling ? 'destructive' : 'primary'}
disabled={saveDisabled}
>
{saving ? 'Saving…' : disabling ? 'Disable website' : 'Save changes'}
</Button>
</footer>
</section>
</div>
);
}
+53 -252
View File
@@ -1,289 +1,90 @@
import { useState, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { Header } from '@/components/layout/header';
import { useBuckets, useCreateBucket, useDeleteBucket, useGrantBucketPermission } from '@/hooks/useApi';
import { useBucketObjects } from '@/hooks/useBucketObjects';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus } from 'lucide-react';
import { useBuckets, useCreateBucket, useDeleteBucket } from '@/hooks/useApi';
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 { BucketWebsiteDialog } from '@/components/buckets/BucketWebsiteDialog';
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
import { PageHeader } from '@/components/ui/page-header';
import { Button } from '@/components/ui/button';
import type { Bucket } from '@/types';
import { toast } from 'sonner';
import { bucketsApi } from '@/lib/api';
export function Buckets() {
const [searchParams, setSearchParams] = useSearchParams();
// Bucket state
const navigate = useNavigate();
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);
const [websiteDialogOpen, setWebsiteDialogOpen] = useState(false);
const [websiteBucket, setWebsiteBucket] = useState<Bucket | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Bucket | null>(null);
const [deleting, setDeleting] = useState(false);
// 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)
);
const { data: buckets = [], isLoading } = useBuckets();
const createMutation = useCreateBucket();
const deleteMutation = useDeleteBucket();
// 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 queryClient = useQueryClient();
const { data: buckets = [], isLoading: bucketsLoading } = useBuckets();
const createBucketMutation = useCreateBucket();
const deleteBucketMutation = useDeleteBucket();
const grantPermissionMutation = useGrantBucketPermission();
const {
objects,
isLoading: objectsLoading,
isRefreshing,
isNavigating,
isTruncated,
nextContinuationToken,
itemsPerPage,
setItemsPerPage,
uploadFiles,
uploadTasks,
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 handleOpenWebsiteSettings = (bucket: Bucket) => {
setWebsiteBucket(bucket);
setWebsiteDialogOpen(true);
};
const handleSaveWebsite = async (
bucketName: string,
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
): Promise<boolean> => {
try {
await bucketsApi.updateBucketWebsite(bucketName, payload);
queryClient.invalidateQueries({ queryKey: ['buckets'] });
toast.success('Website configuration updated');
return true;
} catch {
return false;
}
};
const handleRefreshObjects = async () => {
if (isRefreshing) return;
try {
await fetchObjects(undefined, true);
toast.success('Objects refreshed successfully');
} catch (error) {
console.error('Refresh error:', error);
}
};
// Wrapper functions for mutations to match dialog APIs
const createBucket = async (name: string, region?: string) => {
try {
await createBucketMutation.mutateAsync({ name, region });
await createMutation.mutateAsync({ name, region });
toast.success(`Bucket "${name}" created`);
return true;
} catch (error) {
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Create failed');
return false;
}
};
const deleteBucket = async (name: string) => {
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await deleteBucketMutation.mutateAsync(name);
return true;
} catch (error) {
return false;
await deleteMutation.mutateAsync(deleteTarget.name);
toast.success('Bucket deleted');
setDeleteTarget(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed');
} finally {
setDeleting(false);
}
};
const grantPermission = async (
bucketName: string,
accessKeyId: string,
permissions: { read: boolean; write: boolean; owner: boolean }
) => {
try {
await grantPermissionMutation.mutateAsync({ bucketName, accessKeyId, permissions });
return true;
} catch (error) {
return false;
}
};
// If viewing a bucket's objects, show the object browser view
if (viewingBucket) {
return (
<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}
uploadTasks={uploadTasks}
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" />
<PageHeader
title="Buckets"
subtitle={`${buckets.length} bucket${buckets.length === 1 ? '' : 's'}`}
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus /> Create bucket
</Button>
}
/>
<div className="p-4 sm:p-6">
<BucketListView
buckets={buckets}
searchQuery={searchQuery}
isLoading={bucketsLoading}
isLoading={isLoading}
onSearchChange={setSearchQuery}
onViewBucket={handleViewBucket}
onOpenSettings={handleOpenSettings}
onWebsiteSettings={handleOpenWebsiteSettings}
onCreateBucket={() => setCreateDialogOpen(true)}
onDeleteBucket={(bucket) => {
setSelectedBucket(bucket);
setDeleteBucketDialogOpen(true);
}}
onViewBucket={(name) => navigate(`/buckets/${name}/objects`)}
onOpenSettings={(b) => navigate(`/buckets/${b.name}/settings`)}
onWebsiteSettings={(b) => navigate(`/buckets/${b.name}/website`)}
onDeleteBucket={(b) => setDeleteTarget(b)}
/>
</div>
{/* Dialogs */}
<CreateBucketDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
open={createOpen}
onOpenChange={setCreateOpen}
onCreateBucket={createBucket}
/>
<DeleteBucketDialog
open={deleteBucketDialogOpen}
onOpenChange={setDeleteBucketDialogOpen}
bucket={selectedBucket}
onDeleteBucket={deleteBucket}
/>
<BucketSettingsDialog
open={settingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
bucket={settingsBucket}
onGrantPermission={grantPermission}
/>
<BucketWebsiteDialog
open={websiteDialogOpen}
onOpenChange={setWebsiteDialogOpen}
bucket={websiteBucket}
onSave={handleSaveWebsite}
<DangerousConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => !o && setDeleteTarget(null)}
title={deleteTarget ? `Delete bucket "${deleteTarget.name}"?` : ''}
description="All objects in this bucket will be permanently removed."
confirmationText={deleteTarget?.name ?? ''}
confirmLabel="Delete bucket"
loading={deleting}
onConfirm={confirmDelete}
/>
</div>
);
+5 -5
View File
@@ -1,5 +1,5 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Header} from '@/components/layout/header';
import {PageHeader} from '@/components/ui/page-header';
import {formatBytes} from '@/lib/file-utils';
import {Activity, AlertCircle, CheckCircle2, Clock, Cpu, Database, Info, Network, Server, XCircle,} from 'lucide-react';
import {useQuery} from '@tanstack/react-query';
@@ -83,7 +83,7 @@ export function Cluster() {
if (isLoading) {
return (
<div>
<Header title="Cluster" />
<PageHeader 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>
@@ -96,7 +96,7 @@ export function Cluster() {
return (
<div>
<Header title="Cluster Management" />
<PageHeader title="Cluster management" subtitle="Node layout, partitions, and health" />
<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">
@@ -215,7 +215,7 @@ export function Cluster() {
<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">
<Badge variant={node.isUp ? 'primary' : 'danger'} className="mt-1">
{nodeStatus.label}
</Badge>
</div>
@@ -393,7 +393,7 @@ export function Cluster() {
<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">
<Badge key={feature} variant="neutral">
{feature}
</Badge>
))}
+213 -220
View File
@@ -1,244 +1,237 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Header} from '@/components/layout/header';
import {formatBytes} from '@/lib/file-utils';
import {AlertCircle, Database, FolderOpen, HardDrive, Server, Zap} from 'lucide-react';
import {BucketUsageChart} from '@/components/charts/BucketUsageChart';
import {useDashboardData} from '@/hooks/useApi';
import type {ClusterHealth} from '@/types';
import { AlertCircle, Database, FolderOpen, HardDrive, Server, Zap } from 'lucide-react';
import { PageHeader } from '@/components/ui/page-header';
import { IconTile } from '@/components/ui/icon-tile';
import { EmptyState } from '@/components/ui/empty-state';
import { BucketUsageChart } from '@/components/charts/BucketUsageChart';
import { useDashboardData } from '@/hooks/useApi';
import { formatBytes } from '@/lib/file-utils';
import type { ClusterHealth } from '@/types';
type StatTone = 'primary' | 'destructive' | 'neutral';
type HealthLabel = 'Healthy' | 'Degraded' | 'Unhealthy' | 'Unknown';
function deriveHealth(health: ClusterHealth | null): { label: HealthLabel; tone: StatTone } {
if (!health) return { label: 'Unknown', tone: 'neutral' };
if (
health.storageNodesUp === health.storageNodes &&
health.partitionsAllOk === health.partitions &&
health.connectedNodes === health.knownNodes
) return { label: 'Healthy', tone: 'primary' };
if (health.storageNodesUp > 0 && health.partitionsQuorum > 0) return { label: 'Degraded', tone: 'primary' };
return { label: 'Unhealthy', tone: 'destructive' };
}
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>
);
}
const buckets = bucketsQuery.data ?? [];
const clusterHealth = healthQuery.data ?? null;
const health = deriveHealth(clusterHealth);
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>
<PageHeader
title="Dashboard"
subtitle={
clusterHealth
? `${clusterHealth.connectedNodes}/${clusterHealth.knownNodes} nodes connected`
: 'Loading cluster status…'
}
/>
<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>
{isLoading ? (
<div className="flex min-h-[360px] items-center justify-center">
<div className="text-center">
<div className="inline-block h-6 w-6 animate-spin rounded-full border-2 border-[var(--primary)] border-r-transparent" />
<p className="mt-3 text-[13.5px] text-[var(--muted-foreground)]">Loading dashboard</p>
</div>
</div>
) : (
<div className="space-y-6 px-6 py-5">
{/* KPI row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Total storage"
value={metrics ? formatBytes(metrics.totalSize) : '—'}
sub={`across ${metrics?.bucketCount ?? 0} bucket${metrics?.bucketCount === 1 ? '' : 's'}`}
icon={<HardDrive />}
/>
<StatCard
label="Objects"
value={metrics?.objectCount.toLocaleString() ?? '—'}
sub="files and folders"
icon={<FolderOpen />}
/>
<StatCard
label="Buckets"
value={metrics?.bucketCount.toLocaleString() ?? '—'}
sub="active storage buckets"
icon={<Database />}
/>
<StatCard
label="Cluster"
value={health.label}
valueTone={health.tone}
sub={
clusterHealth
? `${clusterHealth.storageNodesUp}/${clusterHealth.storageNodes} storage nodes`
: '—'
}
icon={health.label === 'Unhealthy' ? <AlertCircle /> : <Zap />}
iconTone={health.tone}
/>
</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>
{/* Cluster row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<StatCard
label="Storage nodes"
value={clusterHealth ? `${clusterHealth.storageNodesUp}/${clusterHealth.storageNodes}` : '—'}
sub="healthy"
icon={<Server />}
/>
<StatCard
label="Partitions"
value={clusterHealth ? `${clusterHealth.partitionsAllOk}/${clusterHealth.partitions}` : '—'}
sub="healthy"
icon={<Zap />}
/>
<StatCard
label="Connected nodes"
value={clusterHealth ? `${clusterHealth.connectedNodes}/${clusterHealth.knownNodes}` : '—'}
sub="cluster membership"
icon={<Server />}
/>
</div>
<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>
{/* Charts */}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<Card title="Storage usage by bucket" description="Distribution of storage across buckets">
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
<BucketUsageChart data={metrics.usageByBucket} />
) : (
<div className="text-center py-8 text-muted-foreground">No data available</div>
<div className="py-8 text-center text-[13.5px] text-[var(--muted-foreground)]">No data available</div>
)}
</CardContent>
</Card>
</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>
<Card title="Breakdown" description="Detailed breakdown of storage across all buckets">
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
<div className="space-y-4">
{metrics.usageByBucket.map((bucket) => (
<div key={bucket.bucketName} className="space-y-1.5">
<div className="flex items-center justify-between gap-2 text-[13.5px]">
<span className="truncate font-medium">{bucket.bucketName}</span>
<div className="flex items-center gap-3 text-[13px] text-[var(--muted-foreground)]">
<span>{bucket.objectCount.toLocaleString()} objects</span>
<span className="font-medium text-[var(--foreground)]">{formatBytes(bucket.size)}</span>
<span className="w-10 text-right">{bucket.percentage.toFixed(1)}%</span>
</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="h-1.5 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full bg-[var(--primary)] transition-all"
style={{ width: `${bucket.percentage}%` }}
/>
</div>
</div>
<div className="min-w-0">
<p className="font-medium truncate">{bucket.name}</p>
<p className="text-xs sm:text-sm text-muted-foreground">
))}
</div>
) : (
<div className="py-8 text-center text-[13.5px] text-[var(--muted-foreground)]">No buckets available</div>
)}
</Card>
</div>
{/* Recent buckets */}
<Card title="Recent buckets" description="Your most recently created buckets">
{buckets.length === 0 ? (
<EmptyState
icon={<Database />}
title="No buckets yet"
description="Create your first bucket from the Buckets page to start storing objects."
tone="neutral"
/>
) : (
<ul className="divide-y divide-[var(--border)]">
{buckets.slice(0, 5).map((bucket) => (
<li key={bucket.name} className="flex items-center gap-3 py-3">
<IconTile icon={<Database />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<p className="truncate text-[14px] font-medium">{bucket.name}</p>
<p className="truncate text-[12.5px] text-[var(--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 className="text-right">
<p className="text-[14px] font-medium">{bucket.objectCount?.toLocaleString() ?? '—'} objects</p>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
{bucket.size ? formatBytes(bucket.size) : '—'}
</p>
</div>
</li>
))}
</ul>
)}
</Card>
</div>
)}
</div>
);
}
function StatCard({
label,
value,
sub,
icon,
iconTone = 'primary',
valueTone = 'neutral',
}: {
label: string;
value: string;
sub?: string;
icon: React.ReactNode;
iconTone?: StatTone;
valueTone?: StatTone;
}) {
const valueColor =
valueTone === 'primary'
? 'text-[var(--primary)]'
: valueTone === 'destructive'
? 'text-[var(--destructive)]'
: 'text-[var(--foreground)]';
return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4">
<div className="flex items-start justify-between gap-3">
<span className="text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
{label}
</span>
<IconTile icon={icon} tone={iconTone} size="sm" />
</div>
<div className={`mt-2 text-[26px] font-semibold tracking-[-0.02em] leading-none ${valueColor}`}>
{value}
</div>
{sub && <div className="mt-1.5 text-[13px] text-[var(--muted-foreground)]">{sub}</div>}
</div>
);
}
function Card({
title,
description,
children,
}: {
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
<header className="border-b border-[var(--border)] px-5 py-3">
<h2 className="text-[15px] font-semibold">{title}</h2>
{description && <p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">{description}</p>}
</header>
<div className="px-5 py-5">{children}</div>
</section>
);
}
+5 -2
View File
@@ -98,8 +98,11 @@ export const useAuthStore = create<AuthStore>()(
isLoading: false,
error: null
});
} catch (error: any) {
const errorMessage = error.response?.data?.error?.message || 'Login failed';
} catch (error) {
const errorMessage =
(error as { response?: { data?: { error?: { message?: string } } } })
.response?.data?.error?.message ||
(error instanceof Error ? error.message : 'Login failed');
set({
error: errorMessage,
isLoading: false,
+12
View File
@@ -7,6 +7,10 @@ export default {
],
theme: {
extend: {
fontFamily: {
sans: ['Geist Sans', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'system-ui', 'sans-serif'],
mono: ['Geist Mono', 'ui-monospace', '"SF Mono"', 'Menlo', 'Consolas', 'monospace'],
},
colors: {
border: 'var(--border)',
input: 'var(--input)',
@@ -16,6 +20,8 @@ export default {
primary: {
DEFAULT: 'var(--primary)',
foreground: 'var(--primary-foreground)',
soft: 'var(--accent-primary-soft)',
softBorder: 'var(--accent-primary-border)',
},
secondary: {
DEFAULT: 'var(--secondary)',
@@ -24,6 +30,11 @@ export default {
destructive: {
DEFAULT: 'var(--destructive)',
foreground: 'var(--destructive-foreground)',
soft: 'var(--danger-soft)',
softBorder: 'var(--danger-border)',
},
success: {
soft: 'var(--success-soft)',
},
muted: {
DEFAULT: 'var(--muted)',
@@ -41,6 +52,7 @@ export default {
DEFAULT: 'var(--card)',
foreground: 'var(--card-foreground)',
},
sunken: 'var(--surface-sunken)',
},
borderRadius: {
lg: 'var(--radius)',
+2 -2
View File
@@ -3,8 +3,8 @@ name: garage-ui
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
icon: https://helm.noste.dev/garage.png
type: application
version: 0.2.3
appVersion: "v0.3.0"
version: 0.2.4
appVersion: "v0.4.0"
keywords:
- garage
- s3
+2 -2
View File
@@ -2,8 +2,8 @@
A Helm chart for deploying [Garage UI](https://github.com/Noooste/garage-ui), a modern web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems.
[![Version](https://img.shields.io/badge/version-0.2.3-blue.svg)](Chart.yaml)
[![App Version](https://img.shields.io/badge/app%20version-v0.3.0-green.svg)](Chart.yaml)
[![Version](https://img.shields.io/badge/version-0.2.4-blue.svg)](Chart.yaml)
[![App Version](https://img.shields.io/badge/app%20version-v0.4.0-green.svg)](Chart.yaml)
## Table of Contents