diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index ccfcab6d6..79bce365b 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -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 diff --git a/weed/admin/dash/bucket_handlers.go b/weed/admin/dash/bucket_handlers.go new file mode 100644 index 000000000..e6edaa217 --- /dev/null +++ b/weed/admin/dash/bucket_handlers.go @@ -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 + }) +} diff --git a/weed/admin/dash/handler_admin.go b/weed/admin/dash/handler_admin.go index 72a59b2ff..a7a783aaf 100644 --- a/weed/admin/dash/handler_admin.go +++ b/weed/admin/dash/handler_admin.go @@ -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 diff --git a/weed/admin/handlers/handlers.go b/weed/admin/handlers/handlers.go index 69b06923f..d57fb0d14 100644 --- a/weed/admin/handlers/handlers.go +++ b/weed/admin/handlers/handlers.go @@ -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 diff --git a/weed/admin/static/js/admin.js b/weed/admin/static/js/admin.js index 6aab0cd37..cefba4c9a 100644 --- a/weed/admin/static/js/admin.js +++ b/weed/admin/static/js/admin.js @@ -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'); + } +} + \ No newline at end of file diff --git a/weed/admin/view/app/s3_buckets.templ b/weed/admin/view/app/s3_buckets.templ index 620aab20b..0ea07068c 100644 --- a/weed/admin/view/app/s3_buckets.templ +++ b/weed/admin/view/app/s3_buckets.templ @@ -135,6 +135,7 @@ templ S3Buckets(data dash.S3BucketsData) { Created Objects Size + Quota Status Actions @@ -152,6 +153,24 @@ templ S3Buckets(data dash.S3BucketsData) { {bucket.CreatedAt.Format("2006-01-02 15:04")} {fmt.Sprintf("%d", bucket.ObjectCount)} {formatBytes(bucket.Size)} + + if bucket.Quota > 0 { +
+ + {formatBytes(bucket.Quota)} + + if bucket.QuotaEnabled { +
+ {fmt.Sprintf("%.1f%% used", float64(bucket.Size)/float64(bucket.Quota)*100)} +
+ } else { +
Disabled
+ } +
+ } else { + No quota + } + {bucket.Status} @@ -169,6 +188,14 @@ templ S3Buckets(data dash.S3BucketsData) { title="View Details"> + + +
+ + +
+ + + } // 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) } \ No newline at end of file diff --git a/weed/admin/view/app/s3_buckets_templ.go b/weed/admin/view/app/s3_buckets_templ.go index 252dde1c2..ce9b1ac82 100644 --- a/weed/admin/view/app/s3_buckets_templ.go +++ b/weed/admin/view/app/s3_buckets_templ.go @@ -86,7 +86,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
Object Store Buckets
Actions:
Export List
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
Object Store Buckets
Actions:
Export List
NameCreatedObjectsSizeStatusActions
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -107,7 +107,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 149, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 150, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -120,7 +120,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.CreatedAt.Format("2006-01-02 15:04")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 152, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 153, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -133,7 +133,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", bucket.ObjectCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 153, Col: 86} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 154, Col: 86} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -146,7 +146,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(bucket.Size)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 154, Col: 73} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 155, Col: 73} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -156,93 +156,210 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 = []any{fmt.Sprintf("badge bg-%s", getBucketStatusColor(bucket.Status))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...) + if bucket.Quota > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 = []any{fmt.Sprintf("badge bg-%s", getQuotaStatusColor(bucket.Size, bucket.Quota, bucket.QuotaEnabled))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(bucket.Quota)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 160, Col: 86} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if bucket.QuotaEnabled { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f%% used", float64(bucket.Size)/float64(bucket.Quota)*100)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 164, Col: 139} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Disabled
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "No quota") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" data-quota-enabled=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", bucket.QuotaEnabled)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 195, Col: 118} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" title=\"Manage Quota\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(data.Buckets) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
NameCreatedObjectsSizeQuotaStatusActions
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 = []any{fmt.Sprintf("badge bg-%s", getBucketStatusColor(bucket.Status))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Status) + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 157, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 176, Col: 66} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
No Object Store buckets found

Create your first bucket to get started with S3 storage.

No Object Store buckets found

Create your first bucket to get started with S3 storage.

Last updated: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
Last updated: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 211, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 238, Col: 81} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Create New S3 Bucket
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
Delete Bucket

Are you sure you want to delete the bucket ?

Warning: This action cannot be undone. All objects in the bucket will be permanently deleted.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
Create New S3 Bucket
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
Set the maximum storage size for this bucket.
Delete Bucket

Are you sure you want to delete the bucket ?

Warning: This action cannot be undone. All objects in the bucket will be permanently deleted.
Manage Bucket Quota
Set the maximum storage size for this bucket. Set to 0 to remove quota.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -274,4 +391,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) +} + var _ = templruntime.GeneratedTemplate