mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 15:58:13 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f48d6f0812 | |||
| 1cd9e734bc | |||
| eb1e7fe96a | |||
| af7af32d4e | |||
| c951e5fa4b | |||
| a828020994 | |||
| dcef1e2cbd | |||
| 13e6fa3d1d | |||
| fece799627 | |||
| c5324db1ad | |||
| bfed48421b | |||
| 38cc1dded0 | |||
| d83313d866 | |||
| 6f10510c49 | |||
| dd0e0d43fb | |||
| 4447f6533f | |||
| 09289371a2 | |||
| 50d33f5dfc |
@@ -1,4 +1,4 @@
|
||||
name: Docker Build and Push
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Release Charts
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -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
@@ -63,4 +63,7 @@ garage.toml
|
||||
|
||||
backend/docs/
|
||||
config.yaml
|
||||
docs/**/*.md
|
||||
docs/**/*.md
|
||||
|
||||
# Superpowers brainstorm / session scratch
|
||||
.superpowers/
|
||||
@@ -4,9 +4,9 @@ A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object st
|
||||
|
||||
[](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
|
||||
[](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
|
||||
[](https://codecov.io/gh/Noooste/garage-ui)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev/)
|
||||
[](https://nodejs.org/)
|
||||
[](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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -294,10 +294,19 @@ func TestGrantBucketPermission_Success(t *testing.T) {
|
||||
}
|
||||
admin.AllowBucketKeyFn = func(_ context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.BucketID != "id-1" || req.AccessKeyID != "AKIA" {
|
||||
t.Errorf("req = %+v", req)
|
||||
t.Errorf("allow req = %+v", req)
|
||||
}
|
||||
if !req.Permissions.Read || !req.Permissions.Write || req.Permissions.Owner {
|
||||
t.Errorf("perms = %+v", req.Permissions)
|
||||
t.Errorf("allow perms = %+v", req.Permissions)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.DenyBucketKeyFn = func(_ context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.BucketID != "id-1" || req.AccessKeyID != "AKIA" {
|
||||
t.Errorf("deny req = %+v", req)
|
||||
}
|
||||
if req.Permissions.Read || req.Permissions.Write || !req.Permissions.Owner {
|
||||
t.Errorf("deny perms = %+v", req.Permissions)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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/"
|
||||
@@ -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
@@ -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 {
|
||||
// error toast handled by axios interceptor
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
// error toast handled by axios interceptor
|
||||
} 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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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 },
|
||||
});
|
||||
setSelectedKey(''); setRead(false); setWrite(false); setOwner(false);
|
||||
} catch {
|
||||
// error toast handled by axios interceptor
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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 { 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);
|
||||
navigate('/buckets');
|
||||
} catch {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
// error toast handled by axios interceptor
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
+50
-253
@@ -1,289 +1,86 @@
|
||||
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> => {
|
||||
const createBucket = async (name: string, region?: string) => {
|
||||
try {
|
||||
await bucketsApi.updateBucketWebsite(bucketName, payload);
|
||||
queryClient.invalidateQueries({ queryKey: ['buckets'] });
|
||||
toast.success('Website configuration updated');
|
||||
await createMutation.mutateAsync({ name, region });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshObjects = async () => {
|
||||
if (isRefreshing) return;
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await fetchObjects(undefined, true);
|
||||
toast.success('Objects refreshed successfully');
|
||||
} catch (error) {
|
||||
console.error('Refresh error:', error);
|
||||
await deleteMutation.mutateAsync(deleteTarget.name);
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
// error toast handled by axios interceptor
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Wrapper functions for mutations to match dialog APIs
|
||||
const createBucket = async (name: string, region?: string) => {
|
||||
try {
|
||||
await createBucketMutation.mutateAsync({ name, region });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBucket = async (name: string) => {
|
||||
try {
|
||||
await deleteBucketMutation.mutateAsync(name);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const grantPermission = async (
|
||||
bucketName: string,
|
||||
accessKeyId: string,
|
||||
permissions: { read: boolean; write: boolean; owner: boolean }
|
||||
) => {
|
||||
try {
|
||||
await grantPermissionMutation.mutateAsync({ bucketName, accessKeyId, permissions });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// If viewing a bucket's objects, show the object browser view
|
||||
if (viewingBucket) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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.2
|
||||
appVersion: "v0.2.2"
|
||||
version: 0.2.5
|
||||
appVersion: "v0.4.2"
|
||||
keywords:
|
||||
- garage
|
||||
- s3
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
|
||||
Reference in New Issue
Block a user