mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2025-08-20 07:02:09 +08:00
add bucket quota
This commit is contained in:
parent
5c2b2e5513
commit
ae1d0a82ce
@ -4,9 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/cluster"
|
||||
@ -83,6 +81,8 @@ type S3Bucket struct {
|
||||
ObjectCount int64 `json:"object_count"`
|
||||
LastModified time.Time `json:"last_modified"`
|
||||
Status string `json:"status"`
|
||||
Quota int64 `json:"quota"` // Quota in bytes, 0 means no quota
|
||||
QuotaEnabled bool `json:"quota_enabled"` // Whether quota is enabled
|
||||
}
|
||||
|
||||
type S3Object struct {
|
||||
@ -499,6 +499,15 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
||||
objectCount = collectionData.FileCount
|
||||
}
|
||||
|
||||
// Get quota information from entry
|
||||
quota := resp.Entry.Quota
|
||||
quotaEnabled := quota > 0
|
||||
if quota < 0 {
|
||||
// Negative quota means disabled
|
||||
quota = -quota
|
||||
quotaEnabled = false
|
||||
}
|
||||
|
||||
bucket := S3Bucket{
|
||||
Name: bucketName,
|
||||
CreatedAt: time.Unix(resp.Entry.Attributes.Crtime, 0),
|
||||
@ -506,6 +515,8 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
||||
ObjectCount: objectCount,
|
||||
LastModified: time.Unix(resp.Entry.Attributes.Mtime, 0),
|
||||
Status: "active",
|
||||
Quota: quota,
|
||||
QuotaEnabled: quotaEnabled,
|
||||
}
|
||||
buckets = append(buckets, bucket)
|
||||
}
|
||||
@ -620,59 +631,7 @@ func (s *AdminServer) listBucketObjects(client filer_pb.SeaweedFilerClient, dire
|
||||
|
||||
// CreateS3Bucket creates a new S3 bucket
|
||||
func (s *AdminServer) CreateS3Bucket(bucketName string) error {
|
||||
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
// First ensure /buckets directory exists
|
||||
_, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||||
Directory: "/",
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: "buckets",
|
||||
IsDirectory: true,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
FileMode: uint32(0755 | os.ModeDir), // Directory mode
|
||||
Uid: uint32(1000),
|
||||
Gid: uint32(1000),
|
||||
Crtime: time.Now().Unix(),
|
||||
Mtime: time.Now().Unix(),
|
||||
TtlSec: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
// Ignore error if directory already exists
|
||||
if err != nil && !strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "existing entry") {
|
||||
return fmt.Errorf("failed to create /buckets directory: %v", err)
|
||||
}
|
||||
|
||||
// Check if bucket already exists
|
||||
_, err = client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: "/buckets",
|
||||
Name: bucketName,
|
||||
})
|
||||
if err == nil {
|
||||
return fmt.Errorf("bucket %s already exists", bucketName)
|
||||
}
|
||||
|
||||
// Create bucket directory under /buckets
|
||||
_, err = client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||||
Directory: "/buckets",
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: bucketName,
|
||||
IsDirectory: true,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
FileMode: uint32(0755 | os.ModeDir), // Directory mode
|
||||
Uid: uint32(1000),
|
||||
Gid: uint32(1000),
|
||||
Crtime: time.Now().Unix(),
|
||||
Mtime: time.Now().Unix(),
|
||||
TtlSec: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bucket directory: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
return s.CreateS3BucketWithQuota(bucketName, 0, false)
|
||||
}
|
||||
|
||||
// DeleteS3Bucket deletes an S3 bucket and all its contents
|
||||
|
325
weed/admin/dash/bucket_handlers.go
Normal file
325
weed/admin/dash/bucket_handlers.go
Normal file
@ -0,0 +1,325 @@
|
||||
package dash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
)
|
||||
|
||||
// S3 Bucket management data structures for templates
|
||||
type S3BucketsData struct {
|
||||
Username string `json:"username"`
|
||||
Buckets []S3Bucket `json:"buckets"`
|
||||
TotalBuckets int `json:"total_buckets"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
type CreateBucketRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Region string `json:"region"`
|
||||
QuotaSize int64 `json:"quota_size"` // Quota size in bytes
|
||||
QuotaUnit string `json:"quota_unit"` // Unit: MB, GB, TB
|
||||
QuotaEnabled bool `json:"quota_enabled"` // Whether quota is enabled
|
||||
}
|
||||
|
||||
// S3 Bucket Management Handlers
|
||||
|
||||
// ShowS3Buckets displays the Object Store buckets management page
|
||||
func (s *AdminServer) ShowS3Buckets(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
|
||||
buckets, err := s.GetS3Buckets()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get Object Store buckets: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
var totalSize int64
|
||||
for _, bucket := range buckets {
|
||||
totalSize += bucket.Size
|
||||
}
|
||||
|
||||
data := S3BucketsData{
|
||||
Username: username,
|
||||
Buckets: buckets,
|
||||
TotalBuckets: len(buckets),
|
||||
TotalSize: totalSize,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// ShowBucketDetails displays detailed information about a specific bucket
|
||||
func (s *AdminServer) ShowBucketDetails(c *gin.Context) {
|
||||
bucketName := c.Param("bucket")
|
||||
if bucketName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
details, err := s.GetBucketDetails(bucketName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get bucket details: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, details)
|
||||
}
|
||||
|
||||
// CreateBucket creates a new S3 bucket
|
||||
func (s *AdminServer) CreateBucket(c *gin.Context) {
|
||||
var req CreateBucketRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate bucket name (basic validation)
|
||||
if len(req.Name) < 3 || len(req.Name) > 63 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name must be between 3 and 63 characters"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert quota to bytes
|
||||
quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit)
|
||||
|
||||
err := s.CreateS3BucketWithQuota(req.Name, quotaBytes, req.QuotaEnabled)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bucket: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Bucket created successfully",
|
||||
"bucket": req.Name,
|
||||
"quota_size": req.QuotaSize,
|
||||
"quota_unit": req.QuotaUnit,
|
||||
"quota_enabled": req.QuotaEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateBucketQuota updates the quota settings for a bucket
|
||||
func (s *AdminServer) UpdateBucketQuota(c *gin.Context) {
|
||||
bucketName := c.Param("bucket")
|
||||
if bucketName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
QuotaSize int64 `json:"quota_size"`
|
||||
QuotaUnit string `json:"quota_unit"`
|
||||
QuotaEnabled bool `json:"quota_enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert quota to bytes
|
||||
quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit)
|
||||
|
||||
err := s.SetBucketQuota(bucketName, quotaBytes, req.QuotaEnabled)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bucket quota: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Bucket quota updated successfully",
|
||||
"bucket": bucketName,
|
||||
"quota_size": req.QuotaSize,
|
||||
"quota_unit": req.QuotaUnit,
|
||||
"quota_enabled": req.QuotaEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBucket deletes an S3 bucket
|
||||
func (s *AdminServer) DeleteBucket(c *gin.Context) {
|
||||
bucketName := c.Param("bucket")
|
||||
if bucketName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.DeleteS3Bucket(bucketName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete bucket: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Bucket deleted successfully",
|
||||
"bucket": bucketName,
|
||||
})
|
||||
}
|
||||
|
||||
// ListBucketsAPI returns the list of buckets as JSON
|
||||
func (s *AdminServer) ListBucketsAPI(c *gin.Context) {
|
||||
buckets, err := s.GetS3Buckets()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get buckets: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"buckets": buckets,
|
||||
"total": len(buckets),
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to convert quota size and unit to bytes
|
||||
func convertQuotaToBytes(size int64, unit string) int64 {
|
||||
if size <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
switch strings.ToUpper(unit) {
|
||||
case "TB":
|
||||
return size * 1024 * 1024 * 1024 * 1024
|
||||
case "GB":
|
||||
return size * 1024 * 1024 * 1024
|
||||
case "MB":
|
||||
return size * 1024 * 1024
|
||||
default:
|
||||
// Default to MB if unit is not recognized
|
||||
return size * 1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert bytes to appropriate unit and size
|
||||
func convertBytesToQuota(bytes int64) (int64, string) {
|
||||
if bytes == 0 {
|
||||
return 0, "MB"
|
||||
}
|
||||
|
||||
// Convert to TB if >= 1TB
|
||||
if bytes >= 1024*1024*1024*1024 && bytes%(1024*1024*1024*1024) == 0 {
|
||||
return bytes / (1024 * 1024 * 1024 * 1024), "TB"
|
||||
}
|
||||
|
||||
// Convert to GB if >= 1GB
|
||||
if bytes >= 1024*1024*1024 && bytes%(1024*1024*1024) == 0 {
|
||||
return bytes / (1024 * 1024 * 1024), "GB"
|
||||
}
|
||||
|
||||
// Convert to MB (default)
|
||||
return bytes / (1024 * 1024), "MB"
|
||||
}
|
||||
|
||||
// SetBucketQuota sets the quota for a bucket
|
||||
func (s *AdminServer) SetBucketQuota(bucketName string, quotaBytes int64, quotaEnabled bool) error {
|
||||
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Get the current bucket entry
|
||||
lookupResp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: "/buckets",
|
||||
Name: bucketName,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("bucket not found: %v", err)
|
||||
}
|
||||
|
||||
bucketEntry := lookupResp.Entry
|
||||
|
||||
// Determine quota value (negative if disabled)
|
||||
var quota int64
|
||||
if quotaEnabled && quotaBytes > 0 {
|
||||
quota = quotaBytes
|
||||
} else if !quotaEnabled && quotaBytes > 0 {
|
||||
quota = -quotaBytes
|
||||
} else {
|
||||
quota = 0
|
||||
}
|
||||
|
||||
// Update the quota
|
||||
bucketEntry.Quota = quota
|
||||
|
||||
// Update the entry
|
||||
_, err = client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
|
||||
Directory: "/buckets",
|
||||
Entry: bucketEntry,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update bucket quota: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateS3BucketWithQuota creates a new S3 bucket with quota settings
|
||||
func (s *AdminServer) CreateS3BucketWithQuota(bucketName string, quotaBytes int64, quotaEnabled bool) error {
|
||||
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
// First ensure /buckets directory exists
|
||||
_, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||||
Directory: "/",
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: "buckets",
|
||||
IsDirectory: true,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
FileMode: uint32(0755 | os.ModeDir), // Directory mode
|
||||
Uid: uint32(1000),
|
||||
Gid: uint32(1000),
|
||||
Crtime: time.Now().Unix(),
|
||||
Mtime: time.Now().Unix(),
|
||||
TtlSec: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
// Ignore error if directory already exists
|
||||
if err != nil && !strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "existing entry") {
|
||||
return fmt.Errorf("failed to create /buckets directory: %v", err)
|
||||
}
|
||||
|
||||
// Check if bucket already exists
|
||||
_, err = client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: "/buckets",
|
||||
Name: bucketName,
|
||||
})
|
||||
if err == nil {
|
||||
return fmt.Errorf("bucket %s already exists", bucketName)
|
||||
}
|
||||
|
||||
// Determine quota value (negative if disabled)
|
||||
var quota int64
|
||||
if quotaEnabled && quotaBytes > 0 {
|
||||
quota = quotaBytes
|
||||
} else if !quotaEnabled && quotaBytes > 0 {
|
||||
quota = -quotaBytes
|
||||
} else {
|
||||
quota = 0
|
||||
}
|
||||
|
||||
// Create bucket directory under /buckets
|
||||
_, err = client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||||
Directory: "/buckets",
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: bucketName,
|
||||
IsDirectory: true,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
FileMode: uint32(0755 | os.ModeDir), // Directory mode
|
||||
Uid: uint32(1000),
|
||||
Gid: uint32(1000),
|
||||
Crtime: time.Now().Unix(),
|
||||
Mtime: time.Now().Unix(),
|
||||
TtlSec: 0,
|
||||
},
|
||||
Quota: quota,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bucket directory: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
@ -25,20 +25,6 @@ type AdminData struct {
|
||||
SystemHealth string `json:"system_health"`
|
||||
}
|
||||
|
||||
// S3 Bucket management data structures for templates
|
||||
type S3BucketsData struct {
|
||||
Username string `json:"username"`
|
||||
Buckets []S3Bucket `json:"buckets"`
|
||||
TotalBuckets int `json:"total_buckets"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
type CreateBucketRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// Object Store Users management structures
|
||||
type ObjectStoreUser struct {
|
||||
Username string `json:"username"`
|
||||
@ -128,112 +114,6 @@ func (s *AdminServer) ShowOverview(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, topology)
|
||||
}
|
||||
|
||||
// S3 Bucket Management Handlers
|
||||
|
||||
// ShowS3Buckets displays the Object Store buckets management page
|
||||
func (s *AdminServer) ShowS3Buckets(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
|
||||
buckets, err := s.GetS3Buckets()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get Object Store buckets: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
var totalSize int64
|
||||
for _, bucket := range buckets {
|
||||
totalSize += bucket.Size
|
||||
}
|
||||
|
||||
data := S3BucketsData{
|
||||
Username: username,
|
||||
Buckets: buckets,
|
||||
TotalBuckets: len(buckets),
|
||||
TotalSize: totalSize,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// ShowBucketDetails displays detailed information about a specific bucket
|
||||
func (s *AdminServer) ShowBucketDetails(c *gin.Context) {
|
||||
bucketName := c.Param("bucket")
|
||||
if bucketName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
details, err := s.GetBucketDetails(bucketName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get bucket details: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, details)
|
||||
}
|
||||
|
||||
// CreateBucket creates a new S3 bucket
|
||||
func (s *AdminServer) CreateBucket(c *gin.Context) {
|
||||
var req CreateBucketRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate bucket name (basic validation)
|
||||
if len(req.Name) < 3 || len(req.Name) > 63 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name must be between 3 and 63 characters"})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.CreateS3Bucket(req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bucket: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Bucket created successfully",
|
||||
"bucket": req.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBucket deletes an S3 bucket
|
||||
func (s *AdminServer) DeleteBucket(c *gin.Context) {
|
||||
bucketName := c.Param("bucket")
|
||||
if bucketName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.DeleteS3Bucket(bucketName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete bucket: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Bucket deleted successfully",
|
||||
"bucket": bucketName,
|
||||
})
|
||||
}
|
||||
|
||||
// ListBucketsAPI returns buckets as JSON API
|
||||
func (s *AdminServer) ListBucketsAPI(c *gin.Context) {
|
||||
buckets, err := s.GetS3Buckets()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"buckets": buckets,
|
||||
"count": len(buckets),
|
||||
})
|
||||
}
|
||||
|
||||
// getMasterNodesStatus checks status of all master nodes
|
||||
func (s *AdminServer) getMasterNodesStatus() []MasterNode {
|
||||
var masterNodes []MasterNode
|
||||
|
@ -80,6 +80,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
|
||||
s3Api.POST("/buckets", h.adminServer.CreateBucket)
|
||||
s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket)
|
||||
s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails)
|
||||
s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota)
|
||||
}
|
||||
|
||||
// File management API routes
|
||||
@ -126,6 +127,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
|
||||
s3Api.POST("/buckets", h.adminServer.CreateBucket)
|
||||
s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket)
|
||||
s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails)
|
||||
s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota)
|
||||
}
|
||||
|
||||
// File management API routes
|
||||
|
@ -357,7 +357,48 @@ function initializeEventHandlers() {
|
||||
const bucketName = button.getAttribute('data-bucket-name');
|
||||
confirmDeleteBucket(bucketName);
|
||||
}
|
||||
|
||||
// Quota management buttons
|
||||
if (e.target.closest('.quota-btn')) {
|
||||
const button = e.target.closest('.quota-btn');
|
||||
const bucketName = button.getAttribute('data-bucket-name');
|
||||
const currentQuota = parseInt(button.getAttribute('data-current-quota')) || 0;
|
||||
const quotaEnabled = button.getAttribute('data-quota-enabled') === 'true';
|
||||
showQuotaModal(bucketName, currentQuota, quotaEnabled);
|
||||
}
|
||||
});
|
||||
|
||||
// Quota form submission
|
||||
const quotaForm = document.getElementById('quotaForm');
|
||||
if (quotaForm) {
|
||||
quotaForm.addEventListener('submit', handleUpdateQuota);
|
||||
}
|
||||
|
||||
// Enable quota checkbox for create bucket form
|
||||
const enableQuotaCheckbox = document.getElementById('enableQuota');
|
||||
if (enableQuotaCheckbox) {
|
||||
enableQuotaCheckbox.addEventListener('change', function() {
|
||||
const quotaSettings = document.getElementById('quotaSettings');
|
||||
if (this.checked) {
|
||||
quotaSettings.style.display = 'block';
|
||||
} else {
|
||||
quotaSettings.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enable quota checkbox for quota modal
|
||||
const quotaEnabledCheckbox = document.getElementById('quotaEnabled');
|
||||
if (quotaEnabledCheckbox) {
|
||||
quotaEnabledCheckbox.addEventListener('change', function() {
|
||||
const quotaSizeSettings = document.getElementById('quotaSizeSettings');
|
||||
if (this.checked) {
|
||||
quotaSizeSettings.style.display = 'block';
|
||||
} else {
|
||||
quotaSizeSettings.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Setup form validation
|
||||
@ -379,7 +420,10 @@ async function handleCreateBucket(event) {
|
||||
const formData = new FormData(form);
|
||||
const bucketData = {
|
||||
name: formData.get('name'),
|
||||
region: formData.get('region') || 'us-east-1'
|
||||
region: formData.get('region') || 'us-east-1',
|
||||
quota_enabled: formData.get('quota_enabled') === 'on',
|
||||
quota_size: parseInt(formData.get('quota_size')) || 0,
|
||||
quota_unit: formData.get('quota_unit') || 'MB'
|
||||
};
|
||||
|
||||
try {
|
||||
@ -491,25 +535,27 @@ function exportBucketList() {
|
||||
const rows = Array.from(table.querySelectorAll('tbody tr'));
|
||||
const data = rows.map(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells.length < 5) return null; // Skip empty state row
|
||||
if (cells.length < 6) return null; // Skip empty state row
|
||||
|
||||
return {
|
||||
name: cells[0].textContent.trim(),
|
||||
created: cells[1].textContent.trim(),
|
||||
objects: cells[2].textContent.trim(),
|
||||
size: cells[3].textContent.trim(),
|
||||
status: cells[4].textContent.trim()
|
||||
quota: cells[4].textContent.trim(),
|
||||
status: cells[5].textContent.trim()
|
||||
};
|
||||
}).filter(item => item !== null);
|
||||
|
||||
// Convert to CSV
|
||||
const csv = [
|
||||
['Name', 'Created', 'Objects', 'Size', 'Status'].join(','),
|
||||
['Name', 'Created', 'Objects', 'Size', 'Quota', 'Status'].join(','),
|
||||
...data.map(row => [
|
||||
row.name,
|
||||
row.created,
|
||||
row.objects,
|
||||
row.size,
|
||||
row.quota,
|
||||
row.status
|
||||
].join(','))
|
||||
].join('\n');
|
||||
@ -1573,4 +1619,97 @@ function getFileIconByName(fileName) {
|
||||
}
|
||||
}
|
||||
|
||||
// Quota Management Functions
|
||||
|
||||
// Show quota management modal
|
||||
function showQuotaModal(bucketName, currentQuotaMB, quotaEnabled) {
|
||||
document.getElementById('quotaBucketName').value = bucketName;
|
||||
document.getElementById('quotaEnabled').checked = quotaEnabled;
|
||||
|
||||
// Convert quota to appropriate unit and set values
|
||||
const quotaBytes = currentQuotaMB * 1024 * 1024; // Convert MB to bytes
|
||||
const { size, unit } = convertBytesToBestUnit(quotaBytes);
|
||||
|
||||
document.getElementById('quotaSizeMB').value = size;
|
||||
document.getElementById('quotaUnitMB').value = unit;
|
||||
|
||||
// Show/hide quota size settings based on enabled state
|
||||
const quotaSizeSettings = document.getElementById('quotaSizeSettings');
|
||||
if (quotaEnabled) {
|
||||
quotaSizeSettings.style.display = 'block';
|
||||
} else {
|
||||
quotaSizeSettings.style.display = 'none';
|
||||
}
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('manageQuotaModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Convert bytes to the best unit (TB, GB, or MB)
|
||||
function convertBytesToBestUnit(bytes) {
|
||||
if (bytes === 0) {
|
||||
return { size: 0, unit: 'MB' };
|
||||
}
|
||||
|
||||
// Check if it's a clean TB value
|
||||
if (bytes >= 1024 * 1024 * 1024 * 1024 && bytes % (1024 * 1024 * 1024 * 1024) === 0) {
|
||||
return { size: bytes / (1024 * 1024 * 1024 * 1024), unit: 'TB' };
|
||||
}
|
||||
|
||||
// Check if it's a clean GB value
|
||||
if (bytes >= 1024 * 1024 * 1024 && bytes % (1024 * 1024 * 1024) === 0) {
|
||||
return { size: bytes / (1024 * 1024 * 1024), unit: 'GB' };
|
||||
}
|
||||
|
||||
// Default to MB
|
||||
return { size: bytes / (1024 * 1024), unit: 'MB' };
|
||||
}
|
||||
|
||||
// Handle quota update form submission
|
||||
async function handleUpdateQuota(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
const bucketName = document.getElementById('quotaBucketName').value;
|
||||
|
||||
const quotaData = {
|
||||
quota_enabled: formData.get('quota_enabled') === 'on',
|
||||
quota_size: parseInt(formData.get('quota_size')) || 0,
|
||||
quota_unit: formData.get('quota_unit') || 'MB'
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/s3/buckets/${bucketName}/quota`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(quotaData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Success
|
||||
showAlert('success', `Quota for bucket "${bucketName}" updated successfully!`);
|
||||
|
||||
// Close modal
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('manageQuotaModal'));
|
||||
modal.hide();
|
||||
|
||||
// Refresh the page after a short delay
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
// Error
|
||||
showAlert('danger', result.error || 'Failed to update bucket quota');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating bucket quota:', error);
|
||||
showAlert('danger', 'Network error occurred while updating bucket quota');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -135,6 +135,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
<th>Created</th>
|
||||
<th>Objects</th>
|
||||
<th>Size</th>
|
||||
<th>Quota</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
@ -152,6 +153,24 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
<td>{bucket.CreatedAt.Format("2006-01-02 15:04")}</td>
|
||||
<td>{fmt.Sprintf("%d", bucket.ObjectCount)}</td>
|
||||
<td>{formatBytes(bucket.Size)}</td>
|
||||
<td>
|
||||
if bucket.Quota > 0 {
|
||||
<div>
|
||||
<span class={fmt.Sprintf("badge bg-%s", getQuotaStatusColor(bucket.Size, bucket.Quota, bucket.QuotaEnabled))}>
|
||||
{formatBytes(bucket.Quota)}
|
||||
</span>
|
||||
if bucket.QuotaEnabled {
|
||||
<div class="small text-muted">
|
||||
{fmt.Sprintf("%.1f%% used", float64(bucket.Size)/float64(bucket.Quota)*100)}
|
||||
</div>
|
||||
} else {
|
||||
<div class="small text-muted">Disabled</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<span class="text-muted">No quota</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class={fmt.Sprintf("badge bg-%s", getBucketStatusColor(bucket.Status))}>
|
||||
{bucket.Status}
|
||||
@ -169,6 +188,14 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<button type="button"
|
||||
class="btn btn-outline-warning btn-sm quota-btn"
|
||||
data-bucket-name={bucket.Name}
|
||||
data-current-quota={fmt.Sprintf("%d", getQuotaInMB(bucket.Quota))}
|
||||
data-quota-enabled={fmt.Sprintf("%t", bucket.QuotaEnabled)}
|
||||
title="Manage Quota">
|
||||
<i class="fas fa-tachometer-alt"></i>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-danger btn-sm delete-bucket-btn"
|
||||
data-bucket-name={bucket.Name}
|
||||
@ -181,7 +208,7 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
}
|
||||
if len(data.Buckets) == 0 {
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-4">
|
||||
<td colspan="7" class="text-center text-muted py-4">
|
||||
<i class="fas fa-cube fa-3x mb-3 text-muted"></i>
|
||||
<div>
|
||||
<h5>No Object Store buckets found</h5>
|
||||
@ -236,6 +263,36 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="enableQuota" name="quota_enabled">
|
||||
<label class="form-check-label" for="enableQuota">
|
||||
Enable Storage Quota
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="quotaSettings" style="display: none;">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<label for="quotaSize" class="form-label">Quota Size</label>
|
||||
<input type="number" class="form-control" id="quotaSize" name="quota_size"
|
||||
placeholder="1024" min="1" step="1">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="quotaUnit" class="form-label">Unit</label>
|
||||
<select class="form-select" id="quotaUnit" name="quota_unit">
|
||||
<option value="MB" selected>MB</option>
|
||||
<option value="GB">GB</option>
|
||||
<option value="TB">TB</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Set the maximum storage size for this bucket.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@ -275,6 +332,64 @@ templ S3Buckets(data dash.S3BucketsData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manage Quota Modal -->
|
||||
<div class="modal fade" id="manageQuotaModal" tabindex="-1" aria-labelledby="manageQuotaModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="manageQuotaModalLabel">
|
||||
<i class="fas fa-tachometer-alt me-2"></i>Manage Bucket Quota
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form id="quotaForm">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Bucket Name</label>
|
||||
<input type="text" class="form-control" id="quotaBucketName" readonly>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="quotaEnabled" name="quota_enabled">
|
||||
<label class="form-check-label" for="quotaEnabled">
|
||||
Enable Storage Quota
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="quotaSizeSettings">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<label for="quotaSizeMB" class="form-label">Quota Size</label>
|
||||
<input type="number" class="form-control" id="quotaSizeMB" name="quota_size"
|
||||
placeholder="1024" min="0" step="1">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="quotaUnitMB" class="form-label">Unit</label>
|
||||
<select class="form-select" id="quotaUnitMB" name="quota_unit">
|
||||
<option value="MB" selected>MB</option>
|
||||
<option value="GB">GB</option>
|
||||
<option value="TB">TB</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Set the maximum storage size for this bucket. Set to 0 to remove quota.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="fas fa-save me-1"></i>Update Quota
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Helper functions for template
|
||||
@ -299,4 +414,26 @@ func countActiveBuckets(buckets []dash.S3Bucket) int {
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func getQuotaStatusColor(used, quota int64, enabled bool) string {
|
||||
if !enabled || quota <= 0 {
|
||||
return "secondary"
|
||||
}
|
||||
|
||||
percentage := float64(used) / float64(quota) * 100
|
||||
if percentage >= 90 {
|
||||
return "danger"
|
||||
} else if percentage >= 75 {
|
||||
return "warning"
|
||||
} else {
|
||||
return "success"
|
||||
}
|
||||
}
|
||||
|
||||
func getQuotaInMB(quotaBytes int64) int64 {
|
||||
if quotaBytes < 0 {
|
||||
quotaBytes = -quotaBytes // Handle disabled quotas (negative values)
|
||||
}
|
||||
return quotaBytes / (1024 * 1024)
|
||||
}
|
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user