diff options
| author | Chris Lu <chrislusf@users.noreply.github.com> | 2025-07-12 01:13:11 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-07-12 01:13:11 -0700 |
| commit | 687a6a6c1de0fb67b51ec9bfd1781a6c255ff695 (patch) | |
| tree | 3ee2890c890e67a170cec2692425528aa9cd795f | |
| parent | 49d43003e1f5063c57cd1b122469c0cb68d0cd79 (diff) | |
| download | seaweedfs-687a6a6c1de0fb67b51ec9bfd1781a6c255ff695.tar.xz seaweedfs-687a6a6c1de0fb67b51ec9bfd1781a6c255ff695.zip | |
Admin UI: Add policies (#6968)
* add policies to UI, accessing filer directly
* view, edit policies
* add back buttons for "users" page
* remove unused
* fix ui dark mode when modal is closed
* bucket view details button
* fix browser buttons
* filer action button works
* clean up masters page
* fix volume servers action buttons
* fix collections page action button
* fix properties page
* more obvious
* fix directory creation file mode
* Update file_browser_handlers.go
* directory permission
41 files changed, 4921 insertions, 2363 deletions
diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index 95bff6deb..9ae5c6ebd 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -94,6 +94,7 @@ func NewAdminServer(masterAddress string, templateFS http.FileSystem, dataDir st glog.V(1).Infof("Set filer client for credential manager: %s", filerAddr) break } + glog.V(1).Infof("Waiting for filer discovery for credential manager...") time.Sleep(5 * time.Second) // Retry every 5 seconds } }() diff --git a/weed/admin/dash/file_browser_data.go b/weed/admin/dash/file_browser_data.go index 3cb878718..6bb30c469 100644 --- a/weed/admin/dash/file_browser_data.go +++ b/weed/admin/dash/file_browser_data.go @@ -99,7 +99,7 @@ func (s *AdminServer) GetFileBrowser(path string) (*FileBrowserData, error) { var ttlSec int32 if entry.Attributes != nil { - mode = formatFileMode(entry.Attributes.FileMode) + mode = FormatFileMode(entry.Attributes.FileMode) uid = entry.Attributes.Uid gid = entry.Attributes.Gid size = int64(entry.Attributes.FileSize) @@ -270,81 +270,3 @@ func (s *AdminServer) generateBreadcrumbs(path string) []BreadcrumbItem { return breadcrumbs } - -// formatFileMode converts file mode to Unix-style string representation (e.g., "drwxr-xr-x") -func formatFileMode(mode uint32) string { - var result []byte = make([]byte, 10) - - // File type - switch mode & 0170000 { // S_IFMT mask - case 0040000: // S_IFDIR - result[0] = 'd' - case 0100000: // S_IFREG - result[0] = '-' - case 0120000: // S_IFLNK - result[0] = 'l' - case 0020000: // S_IFCHR - result[0] = 'c' - case 0060000: // S_IFBLK - result[0] = 'b' - case 0010000: // S_IFIFO - result[0] = 'p' - case 0140000: // S_IFSOCK - result[0] = 's' - default: - result[0] = '-' // S_IFREG is default - } - - // Owner permissions - if mode&0400 != 0 { // S_IRUSR - result[1] = 'r' - } else { - result[1] = '-' - } - if mode&0200 != 0 { // S_IWUSR - result[2] = 'w' - } else { - result[2] = '-' - } - if mode&0100 != 0 { // S_IXUSR - result[3] = 'x' - } else { - result[3] = '-' - } - - // Group permissions - if mode&0040 != 0 { // S_IRGRP - result[4] = 'r' - } else { - result[4] = '-' - } - if mode&0020 != 0 { // S_IWGRP - result[5] = 'w' - } else { - result[5] = '-' - } - if mode&0010 != 0 { // S_IXGRP - result[6] = 'x' - } else { - result[6] = '-' - } - - // Other permissions - if mode&0004 != 0 { // S_IROTH - result[7] = 'r' - } else { - result[7] = '-' - } - if mode&0002 != 0 { // S_IWOTH - result[8] = 'w' - } else { - result[8] = '-' - } - if mode&0001 != 0 { // S_IXOTH - result[9] = 'x' - } else { - result[9] = '-' - } - - return string(result) -} diff --git a/weed/admin/dash/file_mode_utils.go b/weed/admin/dash/file_mode_utils.go new file mode 100644 index 000000000..19c5b2f49 --- /dev/null +++ b/weed/admin/dash/file_mode_utils.go @@ -0,0 +1,85 @@ +package dash + +// FormatFileMode converts file mode to Unix-style string representation (e.g., "drwxr-xr-x") +// Handles both Go's os.ModeDir format and standard Unix file type bits +func FormatFileMode(mode uint32) string { + var result []byte = make([]byte, 10) + + // File type - handle Go's os.ModeDir first, then standard Unix file type bits + if mode&0x80000000 != 0 { // Go's os.ModeDir (0x80000000 = 2147483648) + result[0] = 'd' + } else { + switch mode & 0170000 { // S_IFMT mask + case 0040000: // S_IFDIR + result[0] = 'd' + case 0100000: // S_IFREG + result[0] = '-' + case 0120000: // S_IFLNK + result[0] = 'l' + case 0020000: // S_IFCHR + result[0] = 'c' + case 0060000: // S_IFBLK + result[0] = 'b' + case 0010000: // S_IFIFO + result[0] = 'p' + case 0140000: // S_IFSOCK + result[0] = 's' + default: + result[0] = '-' // S_IFREG is default + } + } + + // Permission bits (always use the lower 12 bits regardless of file type format) + // Owner permissions + if mode&0400 != 0 { // S_IRUSR + result[1] = 'r' + } else { + result[1] = '-' + } + if mode&0200 != 0 { // S_IWUSR + result[2] = 'w' + } else { + result[2] = '-' + } + if mode&0100 != 0 { // S_IXUSR + result[3] = 'x' + } else { + result[3] = '-' + } + + // Group permissions + if mode&0040 != 0 { // S_IRGRP + result[4] = 'r' + } else { + result[4] = '-' + } + if mode&0020 != 0 { // S_IWGRP + result[5] = 'w' + } else { + result[5] = '-' + } + if mode&0010 != 0 { // S_IXGRP + result[6] = 'x' + } else { + result[6] = '-' + } + + // Other permissions + if mode&0004 != 0 { // S_IROTH + result[7] = 'r' + } else { + result[7] = '-' + } + if mode&0002 != 0 { // S_IWOTH + result[8] = 'w' + } else { + result[8] = '-' + } + if mode&0001 != 0 { // S_IXOTH + result[9] = 'x' + } else { + result[9] = '-' + } + + return string(result) +} diff --git a/weed/admin/dash/policies_management.go b/weed/admin/dash/policies_management.go new file mode 100644 index 000000000..8853bbb54 --- /dev/null +++ b/weed/admin/dash/policies_management.go @@ -0,0 +1,225 @@ +package dash + +import ( + "context" + "fmt" + "time" + + "github.com/seaweedfs/seaweedfs/weed/credential" + "github.com/seaweedfs/seaweedfs/weed/glog" +) + +type IAMPolicy struct { + Name string `json:"name"` + Document credential.PolicyDocument `json:"document"` + DocumentJSON string `json:"document_json"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type PoliciesCollection struct { + Policies map[string]credential.PolicyDocument `json:"policies"` +} + +type PoliciesData struct { + Username string `json:"username"` + Policies []IAMPolicy `json:"policies"` + TotalPolicies int `json:"total_policies"` + LastUpdated time.Time `json:"last_updated"` +} + +// Policy management request structures +type CreatePolicyRequest struct { + Name string `json:"name" binding:"required"` + Document credential.PolicyDocument `json:"document" binding:"required"` + DocumentJSON string `json:"document_json"` +} + +type UpdatePolicyRequest struct { + Document credential.PolicyDocument `json:"document" binding:"required"` + DocumentJSON string `json:"document_json"` +} + +// PolicyManager interface is now in the credential package + +// CredentialStorePolicyManager implements credential.PolicyManager by delegating to the credential store +type CredentialStorePolicyManager struct { + credentialManager *credential.CredentialManager +} + +// NewCredentialStorePolicyManager creates a new CredentialStorePolicyManager +func NewCredentialStorePolicyManager(credentialManager *credential.CredentialManager) *CredentialStorePolicyManager { + return &CredentialStorePolicyManager{ + credentialManager: credentialManager, + } +} + +// GetPolicies retrieves all IAM policies via credential store +func (cspm *CredentialStorePolicyManager) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) { + // Get policies from credential store + // We'll use the credential store to access the filer indirectly + // Since policies are stored separately, we need to access the underlying store + store := cspm.credentialManager.GetStore() + glog.V(1).Infof("Getting policies from credential store: %T", store) + + // Check if the store supports policy management + if policyStore, ok := store.(credential.PolicyManager); ok { + glog.V(1).Infof("Store supports policy management, calling GetPolicies") + policies, err := policyStore.GetPolicies(ctx) + if err != nil { + glog.Errorf("Error getting policies from store: %v", err) + return nil, err + } + glog.V(1).Infof("Got %d policies from store", len(policies)) + return policies, nil + } else { + // Fallback: use empty policies for stores that don't support policies + glog.V(1).Infof("Credential store doesn't support policy management, returning empty policies") + return make(map[string]credential.PolicyDocument), nil + } +} + +// CreatePolicy creates a new IAM policy via credential store +func (cspm *CredentialStorePolicyManager) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { + store := cspm.credentialManager.GetStore() + + if policyStore, ok := store.(credential.PolicyManager); ok { + return policyStore.CreatePolicy(ctx, name, document) + } + + return fmt.Errorf("credential store doesn't support policy creation") +} + +// UpdatePolicy updates an existing IAM policy via credential store +func (cspm *CredentialStorePolicyManager) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { + store := cspm.credentialManager.GetStore() + + if policyStore, ok := store.(credential.PolicyManager); ok { + return policyStore.UpdatePolicy(ctx, name, document) + } + + return fmt.Errorf("credential store doesn't support policy updates") +} + +// DeletePolicy deletes an IAM policy via credential store +func (cspm *CredentialStorePolicyManager) DeletePolicy(ctx context.Context, name string) error { + store := cspm.credentialManager.GetStore() + + if policyStore, ok := store.(credential.PolicyManager); ok { + return policyStore.DeletePolicy(ctx, name) + } + + return fmt.Errorf("credential store doesn't support policy deletion") +} + +// GetPolicy retrieves a specific IAM policy via credential store +func (cspm *CredentialStorePolicyManager) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) { + store := cspm.credentialManager.GetStore() + + if policyStore, ok := store.(credential.PolicyManager); ok { + return policyStore.GetPolicy(ctx, name) + } + + return nil, fmt.Errorf("credential store doesn't support policy retrieval") +} + +// AdminServer policy management methods using credential.PolicyManager +func (s *AdminServer) GetPolicyManager() credential.PolicyManager { + if s.credentialManager == nil { + glog.V(1).Infof("Credential manager is nil, policy management not available") + return nil + } + glog.V(1).Infof("Credential manager available, creating CredentialStorePolicyManager") + return NewCredentialStorePolicyManager(s.credentialManager) +} + +// GetPolicies retrieves all IAM policies +func (s *AdminServer) GetPolicies() ([]IAMPolicy, error) { + policyManager := s.GetPolicyManager() + if policyManager == nil { + return nil, fmt.Errorf("policy manager not available") + } + + ctx := context.Background() + policyMap, err := policyManager.GetPolicies(ctx) + if err != nil { + return nil, err + } + + // Convert map[string]PolicyDocument to []IAMPolicy + var policies []IAMPolicy + for name, doc := range policyMap { + policy := IAMPolicy{ + Name: name, + Document: doc, + DocumentJSON: "", // Will be populated if needed + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + policies = append(policies, policy) + } + + return policies, nil +} + +// CreatePolicy creates a new IAM policy +func (s *AdminServer) CreatePolicy(name string, document credential.PolicyDocument) error { + policyManager := s.GetPolicyManager() + if policyManager == nil { + return fmt.Errorf("policy manager not available") + } + + ctx := context.Background() + return policyManager.CreatePolicy(ctx, name, document) +} + +// UpdatePolicy updates an existing IAM policy +func (s *AdminServer) UpdatePolicy(name string, document credential.PolicyDocument) error { + policyManager := s.GetPolicyManager() + if policyManager == nil { + return fmt.Errorf("policy manager not available") + } + + ctx := context.Background() + return policyManager.UpdatePolicy(ctx, name, document) +} + +// DeletePolicy deletes an IAM policy +func (s *AdminServer) DeletePolicy(name string) error { + policyManager := s.GetPolicyManager() + if policyManager == nil { + return fmt.Errorf("policy manager not available") + } + + ctx := context.Background() + return policyManager.DeletePolicy(ctx, name) +} + +// GetPolicy retrieves a specific IAM policy +func (s *AdminServer) GetPolicy(name string) (*IAMPolicy, error) { + policyManager := s.GetPolicyManager() + if policyManager == nil { + return nil, fmt.Errorf("policy manager not available") + } + + ctx := context.Background() + policyDoc, err := policyManager.GetPolicy(ctx, name) + if err != nil { + return nil, err + } + + if policyDoc == nil { + return nil, nil + } + + // Convert PolicyDocument to IAMPolicy + policy := &IAMPolicy{ + Name: name, + Document: *policyDoc, + DocumentJSON: "", // Will be populated if needed + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + return policy, nil +} diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index dc7905bc1..76a123a4f 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -17,6 +17,7 @@ type AdminHandlers struct { clusterHandlers *ClusterHandlers fileBrowserHandlers *FileBrowserHandlers userHandlers *UserHandlers + policyHandlers *PolicyHandlers maintenanceHandlers *MaintenanceHandlers mqHandlers *MessageQueueHandlers } @@ -27,6 +28,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers { clusterHandlers := NewClusterHandlers(adminServer) fileBrowserHandlers := NewFileBrowserHandlers(adminServer) userHandlers := NewUserHandlers(adminServer) + policyHandlers := NewPolicyHandlers(adminServer) maintenanceHandlers := NewMaintenanceHandlers(adminServer) mqHandlers := NewMessageQueueHandlers(adminServer) return &AdminHandlers{ @@ -35,6 +37,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers { clusterHandlers: clusterHandlers, fileBrowserHandlers: fileBrowserHandlers, userHandlers: userHandlers, + policyHandlers: policyHandlers, maintenanceHandlers: maintenanceHandlers, mqHandlers: mqHandlers, } @@ -63,6 +66,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, protected.GET("/object-store/buckets", h.ShowS3Buckets) protected.GET("/object-store/buckets/:bucket", h.ShowBucketDetails) protected.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers) + protected.GET("/object-store/policies", h.policyHandlers.ShowPolicies) // File browser routes protected.GET("/files", h.fileBrowserHandlers.ShowFileBrowser) @@ -121,6 +125,17 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies) } + // Object Store Policy management API routes + objectStorePoliciesApi := api.Group("/object-store/policies") + { + objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies) + objectStorePoliciesApi.POST("", h.policyHandlers.CreatePolicy) + objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy) + objectStorePoliciesApi.PUT("/:name", h.policyHandlers.UpdatePolicy) + objectStorePoliciesApi.DELETE("/:name", h.policyHandlers.DeletePolicy) + objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy) + } + // File management API routes filesApi := api.Group("/files") { @@ -171,6 +186,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, r.GET("/object-store/buckets", h.ShowS3Buckets) r.GET("/object-store/buckets/:bucket", h.ShowBucketDetails) r.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers) + r.GET("/object-store/policies", h.policyHandlers.ShowPolicies) // File browser routes r.GET("/files", h.fileBrowserHandlers.ShowFileBrowser) @@ -229,6 +245,17 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies) } + // Object Store Policy management API routes + objectStorePoliciesApi := api.Group("/object-store/policies") + { + objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies) + objectStorePoliciesApi.POST("", h.policyHandlers.CreatePolicy) + objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy) + objectStorePoliciesApi.PUT("/:name", h.policyHandlers.UpdatePolicy) + objectStorePoliciesApi.DELETE("/:name", h.policyHandlers.DeletePolicy) + objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy) + } + // File management API routes filesApi := api.Group("/files") { diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go index 97621192e..c8e117041 100644 --- a/weed/admin/handlers/file_browser_handlers.go +++ b/weed/admin/handlers/file_browser_handlers.go @@ -8,6 +8,7 @@ import ( "mime/multipart" "net" "net/http" + "os" "path/filepath" "strconv" "strings" @@ -190,7 +191,7 @@ func (h *FileBrowserHandlers) CreateFolder(c *gin.Context) { Name: filepath.Base(fullPath), IsDirectory: true, Attributes: &filer_pb.FuseAttributes{ - FileMode: uint32(0755 | (1 << 31)), // Directory mode + FileMode: uint32(0755 | os.ModeDir), // Directory mode Uid: filer_pb.OS_UID, Gid: filer_pb.OS_GID, Crtime: time.Now().Unix(), @@ -656,8 +657,9 @@ func (h *FileBrowserHandlers) GetFileProperties(c *gin.Context) { properties["created_timestamp"] = entry.Attributes.Crtime } - properties["file_mode"] = fmt.Sprintf("%o", entry.Attributes.FileMode) - properties["file_mode_formatted"] = h.formatFileMode(entry.Attributes.FileMode) + properties["file_mode"] = dash.FormatFileMode(entry.Attributes.FileMode) + properties["file_mode_formatted"] = dash.FormatFileMode(entry.Attributes.FileMode) + properties["file_mode_octal"] = fmt.Sprintf("%o", entry.Attributes.FileMode) properties["uid"] = entry.Attributes.Uid properties["gid"] = entry.Attributes.Gid properties["ttl_seconds"] = entry.Attributes.TtlSec @@ -725,13 +727,6 @@ func (h *FileBrowserHandlers) formatBytes(bytes int64) string { return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } -// Helper function to format file mode -func (h *FileBrowserHandlers) formatFileMode(mode uint32) string { - // Convert to octal and format as rwx permissions - perm := mode & 0777 - return fmt.Sprintf("%03o", perm) -} - // Helper function to determine MIME type from filename func (h *FileBrowserHandlers) determineMimeType(filename string) string { ext := strings.ToLower(filepath.Ext(filename)) diff --git a/weed/admin/handlers/maintenance_handlers.go b/weed/admin/handlers/maintenance_handlers.go index 954874c14..4b1f91387 100644 --- a/weed/admin/handlers/maintenance_handlers.go +++ b/weed/admin/handlers/maintenance_handlers.go @@ -11,9 +11,6 @@ import ( "github.com/seaweedfs/seaweedfs/weed/admin/view/components" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" "github.com/seaweedfs/seaweedfs/weed/worker/tasks" - "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance" - "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding" - "github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum" "github.com/seaweedfs/seaweedfs/weed/worker/types" ) @@ -114,59 +111,60 @@ func (h *MaintenanceHandlers) ShowTaskConfig(c *gin.Context) { return } - // Try to get templ UI provider first - templUIProvider := getTemplUIProvider(taskType) + // Try to get templ UI provider first - temporarily disabled + // templUIProvider := getTemplUIProvider(taskType) var configSections []components.ConfigSectionData - if templUIProvider != nil { - // Use the new templ-based UI provider - currentConfig := templUIProvider.GetCurrentConfig() - sections, err := templUIProvider.RenderConfigSections(currentConfig) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render configuration sections: " + err.Error()}) - return - } - configSections = sections - } else { - // Fallback to basic configuration for providers that haven't been migrated yet - configSections = []components.ConfigSectionData{ - { - Title: "Configuration Settings", - Icon: "fas fa-cogs", - Description: "Configure task detection and scheduling parameters", - Fields: []interface{}{ - components.CheckboxFieldData{ - FormFieldData: components.FormFieldData{ - Name: "enabled", - Label: "Enable Task", - Description: "Whether this task type should be enabled", - }, - Checked: true, + // Temporarily disabled templ UI provider + // if templUIProvider != nil { + // // Use the new templ-based UI provider + // currentConfig := templUIProvider.GetCurrentConfig() + // sections, err := templUIProvider.RenderConfigSections(currentConfig) + // if err != nil { + // c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render configuration sections: " + err.Error()}) + // return + // } + // configSections = sections + // } else { + // Fallback to basic configuration for providers that haven't been migrated yet + configSections = []components.ConfigSectionData{ + { + Title: "Configuration Settings", + Icon: "fas fa-cogs", + Description: "Configure task detection and scheduling parameters", + Fields: []interface{}{ + components.CheckboxFieldData{ + FormFieldData: components.FormFieldData{ + Name: "enabled", + Label: "Enable Task", + Description: "Whether this task type should be enabled", }, - components.NumberFieldData{ - FormFieldData: components.FormFieldData{ - Name: "max_concurrent", - Label: "Max Concurrent Tasks", - Description: "Maximum number of concurrent tasks", - Required: true, - }, - Value: 2, - Step: "1", - Min: floatPtr(1), + Checked: true, + }, + components.NumberFieldData{ + FormFieldData: components.FormFieldData{ + Name: "max_concurrent", + Label: "Max Concurrent Tasks", + Description: "Maximum number of concurrent tasks", + Required: true, }, - components.DurationFieldData{ - FormFieldData: components.FormFieldData{ - Name: "scan_interval", - Label: "Scan Interval", - Description: "How often to scan for tasks", - Required: true, - }, - Value: "30m", + Value: 2, + Step: "1", + Min: floatPtr(1), + }, + components.DurationFieldData{ + FormFieldData: components.FormFieldData{ + Name: "scan_interval", + Label: "Scan Interval", + Description: "How often to scan for tasks", + Required: true, }, + Value: "30m", }, }, - } + }, } + // } // End of disabled templ UI provider else block // Create task configuration data using templ components configData := &app.TaskConfigTemplData{ @@ -199,8 +197,8 @@ func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) { return } - // Try to get templ UI provider first - templUIProvider := getTemplUIProvider(taskType) + // Try to get templ UI provider first - temporarily disabled + // templUIProvider := getTemplUIProvider(taskType) // Parse form data err := c.Request.ParseForm() @@ -217,52 +215,53 @@ func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) { var config interface{} - if templUIProvider != nil { - // Use the new templ-based UI provider - config, err = templUIProvider.ParseConfigForm(formData) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()}) - return - } + // Temporarily disabled templ UI provider + // if templUIProvider != nil { + // // Use the new templ-based UI provider + // config, err = templUIProvider.ParseConfigForm(formData) + // if err != nil { + // c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()}) + // return + // } + // // Apply configuration using templ provider + // err = templUIProvider.ApplyConfig(config) + // if err != nil { + // c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) + // return + // } + // } else { + // Fallback to old UI provider for tasks that haven't been migrated yet + // Fallback to old UI provider for tasks that haven't been migrated yet + uiRegistry := tasks.GetGlobalUIRegistry() + typesRegistry := tasks.GetGlobalTypesRegistry() - // Apply configuration using templ provider - err = templUIProvider.ApplyConfig(config) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) - return - } - } else { - // Fallback to old UI provider for tasks that haven't been migrated yet - uiRegistry := tasks.GetGlobalUIRegistry() - typesRegistry := tasks.GetGlobalTypesRegistry() - - var provider types.TaskUIProvider - for workerTaskType := range typesRegistry.GetAllDetectors() { - if string(workerTaskType) == string(taskType) { - provider = uiRegistry.GetProvider(workerTaskType) - break - } + var provider types.TaskUIProvider + for workerTaskType := range typesRegistry.GetAllDetectors() { + if string(workerTaskType) == string(taskType) { + provider = uiRegistry.GetProvider(workerTaskType) + break } + } - if provider == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "UI provider not found for task type"}) - return - } + if provider == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "UI provider not found for task type"}) + return + } - // Parse configuration from form using old provider - config, err = provider.ParseConfigForm(formData) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()}) - return - } + // Parse configuration from form using old provider + config, err = provider.ParseConfigForm(formData) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()}) + return + } - // Apply configuration using old provider - err = provider.ApplyConfig(config) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) - return - } + // Apply configuration using old provider + err = provider.ApplyConfig(config) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) + return } + // } // End of disabled templ UI provider else block // Redirect back to task configuration page c.Redirect(http.StatusSeeOther, "/maintenance/config/"+taskTypeName) @@ -350,39 +349,35 @@ func floatPtr(f float64) *float64 { return &f } -// Global templ UI registry -var globalTemplUIRegistry *types.UITemplRegistry +// Global templ UI registry - temporarily disabled +// var globalTemplUIRegistry *types.UITemplRegistry -// initTemplUIRegistry initializes the global templ UI registry +// initTemplUIRegistry initializes the global templ UI registry - temporarily disabled func initTemplUIRegistry() { - if globalTemplUIRegistry == nil { - globalTemplUIRegistry = types.NewUITemplRegistry() - - // Register vacuum templ UI provider using shared instances - vacuumDetector, vacuumScheduler := vacuum.GetSharedInstances() - vacuum.RegisterUITempl(globalTemplUIRegistry, vacuumDetector, vacuumScheduler) - - // Register erasure coding templ UI provider using shared instances - erasureCodingDetector, erasureCodingScheduler := erasure_coding.GetSharedInstances() - erasure_coding.RegisterUITempl(globalTemplUIRegistry, erasureCodingDetector, erasureCodingScheduler) - - // Register balance templ UI provider using shared instances - balanceDetector, balanceScheduler := balance.GetSharedInstances() - balance.RegisterUITempl(globalTemplUIRegistry, balanceDetector, balanceScheduler) - } + // Temporarily disabled due to missing types + // if globalTemplUIRegistry == nil { + // globalTemplUIRegistry = types.NewUITemplRegistry() + // // Register vacuum templ UI provider using shared instances + // vacuumDetector, vacuumScheduler := vacuum.GetSharedInstances() + // vacuum.RegisterUITempl(globalTemplUIRegistry, vacuumDetector, vacuumScheduler) + // // Register erasure coding templ UI provider using shared instances + // erasureCodingDetector, erasureCodingScheduler := erasure_coding.GetSharedInstances() + // erasure_coding.RegisterUITempl(globalTemplUIRegistry, erasureCodingDetector, erasureCodingScheduler) + // // Register balance templ UI provider using shared instances + // balanceDetector, balanceScheduler := balance.GetSharedInstances() + // balance.RegisterUITempl(globalTemplUIRegistry, balanceDetector, balanceScheduler) + // } } -// getTemplUIProvider gets the templ UI provider for a task type -func getTemplUIProvider(taskType maintenance.MaintenanceTaskType) types.TaskUITemplProvider { - initTemplUIRegistry() - +// getTemplUIProvider gets the templ UI provider for a task type - temporarily disabled +func getTemplUIProvider(taskType maintenance.MaintenanceTaskType) interface{} { + // initTemplUIRegistry() // Convert maintenance task type to worker task type - typesRegistry := tasks.GetGlobalTypesRegistry() - for workerTaskType := range typesRegistry.GetAllDetectors() { - if string(workerTaskType) == string(taskType) { - return globalTemplUIRegistry.GetProvider(workerTaskType) - } - } - + // typesRegistry := tasks.GetGlobalTypesRegistry() + // for workerTaskType := range typesRegistry.GetAllDetectors() { + // if string(workerTaskType) == string(taskType) { + // return globalTemplUIRegistry.GetProvider(workerTaskType) + // } + // } return nil } diff --git a/weed/admin/handlers/policy_handlers.go b/weed/admin/handlers/policy_handlers.go new file mode 100644 index 000000000..8f5cc91b1 --- /dev/null +++ b/weed/admin/handlers/policy_handlers.go @@ -0,0 +1,273 @@ +package handlers + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" + "github.com/seaweedfs/seaweedfs/weed/admin/view/app" + "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" + "github.com/seaweedfs/seaweedfs/weed/credential" + "github.com/seaweedfs/seaweedfs/weed/glog" +) + +// PolicyHandlers contains all the HTTP handlers for policy management +type PolicyHandlers struct { + adminServer *dash.AdminServer +} + +// NewPolicyHandlers creates a new instance of PolicyHandlers +func NewPolicyHandlers(adminServer *dash.AdminServer) *PolicyHandlers { + return &PolicyHandlers{ + adminServer: adminServer, + } +} + +// ShowPolicies renders the policies management page +func (h *PolicyHandlers) ShowPolicies(c *gin.Context) { + // Get policies data from the server + policiesData := h.getPoliciesData(c) + + // Render HTML template + c.Header("Content-Type", "text/html") + policiesComponent := app.Policies(policiesData) + layoutComponent := layout.Layout(c, policiesComponent) + err := layoutComponent.Render(c.Request.Context(), c.Writer) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) + return + } +} + +// GetPolicies returns the list of policies as JSON +func (h *PolicyHandlers) GetPolicies(c *gin.Context) { + policies, err := h.adminServer.GetPolicies() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policies: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"policies": policies}) +} + +// CreatePolicy handles policy creation +func (h *PolicyHandlers) CreatePolicy(c *gin.Context) { + var req dash.CreatePolicyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + // Validate policy name + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"}) + return + } + + // Check if policy already exists + existingPolicy, err := h.adminServer.GetPolicy(req.Name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()}) + return + } + if existingPolicy != nil { + c.JSON(http.StatusConflict, gin.H{"error": "Policy with this name already exists"}) + return + } + + // Create the policy + err = h.adminServer.CreatePolicy(req.Name, req.Document) + if err != nil { + glog.Errorf("Failed to create policy %s: %v", req.Name, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create policy: " + err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "message": "Policy created successfully", + "policy": req.Name, + }) +} + +// GetPolicy returns a specific policy +func (h *PolicyHandlers) GetPolicy(c *gin.Context) { + policyName := c.Param("name") + if policyName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"}) + return + } + + policy, err := h.adminServer.GetPolicy(policyName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policy: " + err.Error()}) + return + } + + if policy == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) + return + } + + c.JSON(http.StatusOK, policy) +} + +// UpdatePolicy handles policy updates +func (h *PolicyHandlers) UpdatePolicy(c *gin.Context) { + policyName := c.Param("name") + if policyName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"}) + return + } + + var req dash.UpdatePolicyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + // Check if policy exists + existingPolicy, err := h.adminServer.GetPolicy(policyName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()}) + return + } + if existingPolicy == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) + return + } + + // Update the policy + err = h.adminServer.UpdatePolicy(policyName, req.Document) + if err != nil { + glog.Errorf("Failed to update policy %s: %v", policyName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update policy: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Policy updated successfully", + "policy": policyName, + }) +} + +// DeletePolicy handles policy deletion +func (h *PolicyHandlers) DeletePolicy(c *gin.Context) { + policyName := c.Param("name") + if policyName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"}) + return + } + + // Check if policy exists + existingPolicy, err := h.adminServer.GetPolicy(policyName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()}) + return + } + if existingPolicy == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) + return + } + + // Delete the policy + err = h.adminServer.DeletePolicy(policyName) + if err != nil { + glog.Errorf("Failed to delete policy %s: %v", policyName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete policy: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Policy deleted successfully", + "policy": policyName, + }) +} + +// ValidatePolicy validates a policy document without saving it +func (h *PolicyHandlers) ValidatePolicy(c *gin.Context) { + var req struct { + Document credential.PolicyDocument `json:"document" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + // Basic validation + if req.Document.Version == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Policy version is required"}) + return + } + + if len(req.Document.Statement) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Policy must have at least one statement"}) + return + } + + // Validate each statement + for i, statement := range req.Document.Statement { + if statement.Effect != "Allow" && statement.Effect != "Deny" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Statement %d: Effect must be 'Allow' or 'Deny'", i+1), + }) + return + } + + if len(statement.Action) == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Statement %d: Action is required", i+1), + }) + return + } + + if len(statement.Resource) == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Statement %d: Resource is required", i+1), + }) + return + } + } + + c.JSON(http.StatusOK, gin.H{ + "valid": true, + "message": "Policy document is valid", + }) +} + +// getPoliciesData retrieves policies data from the server +func (h *PolicyHandlers) getPoliciesData(c *gin.Context) dash.PoliciesData { + username := c.GetString("username") + if username == "" { + username = "admin" + } + + // Get policies + policies, err := h.adminServer.GetPolicies() + if err != nil { + glog.Errorf("Failed to get policies: %v", err) + // Return empty data on error + return dash.PoliciesData{ + Username: username, + Policies: []dash.IAMPolicy{}, + TotalPolicies: 0, + LastUpdated: time.Now(), + } + } + + // Ensure policies is never nil + if policies == nil { + policies = []dash.IAMPolicy{} + } + + return dash.PoliciesData{ + Username: username, + Policies: policies, + TotalPolicies: len(policies), + LastUpdated: time.Now(), + } +} diff --git a/weed/admin/view/app/cluster_collections.templ b/weed/admin/view/app/cluster_collections.templ index 2bd21a3ca..9099fe112 100644 --- a/weed/admin/view/app/cluster_collections.templ +++ b/weed/admin/view/app/cluster_collections.templ @@ -164,22 +164,18 @@ templ ClusterCollections(data dash.ClusterCollectionsData) { } </td> <td> - <div class="btn-group btn-group-sm"> - <button type="button" class="btn btn-outline-primary btn-sm" - title="View Details"> - <i class="fas fa-eye"></i> - </button> - <button type="button" class="btn btn-outline-secondary btn-sm" - title="Edit"> - <i class="fas fa-edit"></i> - </button> - <button type="button" class="btn btn-outline-danger btn-sm" - title="Delete" - data-collection-name={collection.Name} - onclick="confirmDeleteCollection(this)"> - <i class="fas fa-trash"></i> - </button> - </div> + <button type="button" + class="btn btn-outline-primary btn-sm" + title="View Details" + data-action="view-details" + data-name={collection.Name} + data-datacenter={collection.DataCenter} + data-volume-count={fmt.Sprintf("%d", collection.VolumeCount)} + data-file-count={fmt.Sprintf("%d", collection.FileCount)} + data-total-size={fmt.Sprintf("%d", collection.TotalSize)} + data-disk-types={formatDiskTypes(collection.DiskTypes)}> + <i class="fas fa-eye"></i> + </button> </td> </tr> } @@ -209,30 +205,169 @@ templ ClusterCollections(data dash.ClusterCollectionsData) { - <!-- Delete Confirmation Modal --> - <div class="modal fade" id="deleteCollectionModal" tabindex="-1"> - <div class="modal-dialog"> - <div class="modal-content"> - <div class="modal-header"> - <h5 class="modal-title text-danger"> - <i class="fas fa-exclamation-triangle me-2"></i>Delete Collection - </h5> - <button type="button" class="btn-close" data-bs-dismiss="modal"></button> - </div> - <div class="modal-body"> - <p>Are you sure you want to delete the collection <strong id="deleteCollectionName"></strong>?</p> - <div class="alert alert-warning"> - <i class="fas fa-warning me-2"></i> - This action cannot be undone. All volumes in this collection will be affected. - </div> - </div> - <div class="modal-footer"> - <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> - <button type="button" class="btn btn-danger" id="confirmDeleteCollection">Delete Collection</button> - </div> - </div> - </div> - </div> + + + <!-- JavaScript for cluster collections functionality --> + <script> + document.addEventListener('DOMContentLoaded', function() { + // Handle collection action buttons + document.addEventListener('click', function(e) { + const button = e.target.closest('[data-action]'); + if (!button) return; + + const action = button.getAttribute('data-action'); + + switch(action) { + case 'view-details': + const collectionData = { + name: button.getAttribute('data-name'), + datacenter: button.getAttribute('data-datacenter'), + volumeCount: parseInt(button.getAttribute('data-volume-count')), + fileCount: parseInt(button.getAttribute('data-file-count')), + totalSize: parseInt(button.getAttribute('data-total-size')), + diskTypes: button.getAttribute('data-disk-types') + }; + showCollectionDetails(collectionData); + break; + } + }); + }); + + function showCollectionDetails(collection) { + const modalHtml = '<div class="modal fade" id="collectionDetailsModal" tabindex="-1">' + + '<div class="modal-dialog modal-lg">' + + '<div class="modal-content">' + + '<div class="modal-header">' + + '<h5 class="modal-title"><i class="fas fa-layer-group me-2"></i>Collection Details: ' + collection.name + '</h5>' + + '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' + + '</div>' + + '<div class="modal-body">' + + '<div class="row">' + + '<div class="col-md-6">' + + '<h6 class="text-primary"><i class="fas fa-info-circle me-1"></i>Basic Information</h6>' + + '<table class="table table-sm">' + + '<tr><td><strong>Collection Name:</strong></td><td><code>' + collection.name + '</code></td></tr>' + + '<tr><td><strong>Data Center:</strong></td><td>' + + (collection.datacenter ? '<span class="badge bg-light text-dark">' + collection.datacenter + '</span>' : '<span class="text-muted">N/A</span>') + + '</td></tr>' + + '<tr><td><strong>Disk Types:</strong></td><td>' + + (collection.diskTypes ? collection.diskTypes.split(', ').map(type => + '<span class="badge bg-' + getDiskTypeBadgeColor(type) + ' me-1">' + type + '</span>' + ).join('') : '<span class="text-muted">Unknown</span>') + + '</td></tr>' + + '</table>' + + '</div>' + + '<div class="col-md-6">' + + '<h6 class="text-primary"><i class="fas fa-chart-bar me-1"></i>Storage Statistics</h6>' + + '<table class="table table-sm">' + + '<tr><td><strong>Total Volumes:</strong></td><td>' + + '<div class="d-flex align-items-center">' + + '<i class="fas fa-database me-2 text-muted"></i>' + + '<span>' + collection.volumeCount.toLocaleString() + '</span>' + + '</div>' + + '</td></tr>' + + '<tr><td><strong>Total Files:</strong></td><td>' + + '<div class="d-flex align-items-center">' + + '<i class="fas fa-file me-2 text-muted"></i>' + + '<span>' + collection.fileCount.toLocaleString() + '</span>' + + '</div>' + + '</td></tr>' + + '<tr><td><strong>Total Size:</strong></td><td>' + + '<div class="d-flex align-items-center">' + + '<i class="fas fa-hdd me-2 text-muted"></i>' + + '<span>' + formatBytes(collection.totalSize) + '</span>' + + '</div>' + + '</td></tr>' + + '</table>' + + '</div>' + + '</div>' + + '<div class="row mt-3">' + + '<div class="col-12">' + + '<h6 class="text-primary"><i class="fas fa-link me-1"></i>Quick Actions</h6>' + + '<div class="d-grid gap-2 d-md-flex">' + + '<a href="/cluster/volumes?collection=' + encodeURIComponent(collection.name) + '" class="btn btn-outline-primary">' + + '<i class="fas fa-database me-1"></i>View Volumes' + + '</a>' + + '<a href="/files?collection=' + encodeURIComponent(collection.name) + '" class="btn btn-outline-info">' + + '<i class="fas fa-folder me-1"></i>Browse Files' + + '</a>' + + '</div>' + + '</div>' + + '</div>' + + '</div>' + + '<div class="modal-footer">' + + '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' + + '</div>' + + '</div>' + + '</div>' + + '</div>'; + + // Remove existing modal if present + const existingModal = document.getElementById('collectionDetailsModal'); + if (existingModal) { + existingModal.remove(); + } + + // Add modal to body and show + document.body.insertAdjacentHTML('beforeend', modalHtml); + const modal = new bootstrap.Modal(document.getElementById('collectionDetailsModal')); + modal.show(); + + // Remove modal when hidden + document.getElementById('collectionDetailsModal').addEventListener('hidden.bs.modal', function() { + this.remove(); + }); + } + + function getDiskTypeBadgeColor(diskType) { + switch(diskType.toLowerCase()) { + case 'ssd': + return 'primary'; + case 'hdd': + case '': + return 'secondary'; + default: + return 'info'; + } + } + + function formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + function exportCollections() { + // Simple CSV export of collections list + const rows = Array.from(document.querySelectorAll('#collectionsTable tbody tr')).map(row => { + const cells = row.querySelectorAll('td'); + if (cells.length > 1) { + return { + name: cells[0].textContent.trim(), + volumes: cells[1].textContent.trim(), + files: cells[2].textContent.trim(), + size: cells[3].textContent.trim(), + diskTypes: cells[4].textContent.trim() + }; + } + return null; + }).filter(row => row !== null); + + const csvContent = "data:text/csv;charset=utf-8," + + "Collection Name,Volumes,Files,Size,Disk Types\n" + + rows.map(r => '"' + r.name + '","' + r.volumes + '","' + r.files + '","' + r.size + '","' + r.diskTypes + '"').join("\n"); + + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", "collections.csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + </script> } func getDiskTypeColor(diskType string) string { diff --git a/weed/admin/view/app/cluster_collections_templ.go b/weed/admin/view/app/cluster_collections_templ.go index 8c675695a..58384c462 100644 --- a/weed/admin/view/app/cluster_collections_templ.go +++ b/weed/admin/view/app/cluster_collections_templ.go @@ -231,48 +231,113 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Edit\"><i class=\"fas fa-edit\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger btn-sm\" title=\"Delete\" data-collection-name=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\" data-action=\"view-details\" data-name=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 178, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 171, Col: 78} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" onclick=\"confirmDeleteCollection(this)\"><i class=\"fas fa-trash\"></i></button></div></td></tr>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-datacenter=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(collection.DataCenter) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 172, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-volume-count=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.VolumeCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 173, Col: 112} + } + _, 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, 23, "\" data-file-count=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.FileCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 174, Col: 108} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" data-total-size=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.TotalSize)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 175, Col: 108} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-disk-types=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(formatDiskTypes(collection.DiskTypes)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 176, Col: 106} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"><i class=\"fas fa-eye\"></i></button></td></tr>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</tbody></table></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</tbody></table></div>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"text-center py-5\"><i class=\"fas fa-layer-group fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Collections Found</h5><p class=\"text-muted\">No collections are currently configured in the cluster.</p></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"text-center py-5\"><i class=\"fas fa-layer-group fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Collections Found</h5><p class=\"text-muted\">No collections are currently configured in the cluster.</p></div>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, 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/cluster_collections.templ`, Line: 204, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 200, Col: 81} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</small></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteCollectionModal\" tabindex=\"-1\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title text-danger\"><i class=\"fas fa-exclamation-triangle me-2\"></i>Delete Collection</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the collection <strong id=\"deleteCollectionName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-warning me-2\"></i> This action cannot be undone. All volumes in this collection will be affected.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" id=\"confirmDeleteCollection\">Delete Collection</button></div></div></div></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</small></div></div></div><!-- JavaScript for cluster collections functionality --><script>\n document.addEventListener('DOMContentLoaded', function() {\n // Handle collection action buttons\n document.addEventListener('click', function(e) {\n const button = e.target.closest('[data-action]');\n if (!button) return;\n \n const action = button.getAttribute('data-action');\n \n switch(action) {\n case 'view-details':\n const collectionData = {\n name: button.getAttribute('data-name'),\n datacenter: button.getAttribute('data-datacenter'),\n volumeCount: parseInt(button.getAttribute('data-volume-count')),\n fileCount: parseInt(button.getAttribute('data-file-count')),\n totalSize: parseInt(button.getAttribute('data-total-size')),\n diskTypes: button.getAttribute('data-disk-types')\n };\n showCollectionDetails(collectionData);\n break;\n }\n });\n });\n \n function showCollectionDetails(collection) {\n const modalHtml = '<div class=\"modal fade\" id=\"collectionDetailsModal\" tabindex=\"-1\">' +\n '<div class=\"modal-dialog modal-lg\">' +\n '<div class=\"modal-content\">' +\n '<div class=\"modal-header\">' +\n '<h5 class=\"modal-title\"><i class=\"fas fa-layer-group me-2\"></i>Collection Details: ' + collection.name + '</h5>' +\n '<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n '</div>' +\n '<div class=\"modal-body\">' +\n '<div class=\"row\">' +\n '<div class=\"col-md-6\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-info-circle me-1\"></i>Basic Information</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr><td><strong>Collection Name:</strong></td><td><code>' + collection.name + '</code></td></tr>' +\n '<tr><td><strong>Data Center:</strong></td><td>' +\n (collection.datacenter ? '<span class=\"badge bg-light text-dark\">' + collection.datacenter + '</span>' : '<span class=\"text-muted\">N/A</span>') +\n '</td></tr>' +\n '<tr><td><strong>Disk Types:</strong></td><td>' +\n (collection.diskTypes ? collection.diskTypes.split(', ').map(type => \n '<span class=\"badge bg-' + getDiskTypeBadgeColor(type) + ' me-1\">' + type + '</span>'\n ).join('') : '<span class=\"text-muted\">Unknown</span>') +\n '</td></tr>' +\n '</table>' +\n '</div>' +\n '<div class=\"col-md-6\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-chart-bar me-1\"></i>Storage Statistics</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr><td><strong>Total Volumes:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<i class=\"fas fa-database me-2 text-muted\"></i>' +\n '<span>' + collection.volumeCount.toLocaleString() + '</span>' +\n '</div>' +\n '</td></tr>' +\n '<tr><td><strong>Total Files:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<i class=\"fas fa-file me-2 text-muted\"></i>' +\n '<span>' + collection.fileCount.toLocaleString() + '</span>' +\n '</div>' +\n '</td></tr>' +\n '<tr><td><strong>Total Size:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<i class=\"fas fa-hdd me-2 text-muted\"></i>' +\n '<span>' + formatBytes(collection.totalSize) + '</span>' +\n '</div>' +\n '</td></tr>' +\n '</table>' +\n '</div>' +\n '</div>' +\n '<div class=\"row mt-3\">' +\n '<div class=\"col-12\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-link me-1\"></i>Quick Actions</h6>' +\n '<div class=\"d-grid gap-2 d-md-flex\">' +\n '<a href=\"/cluster/volumes?collection=' + encodeURIComponent(collection.name) + '\" class=\"btn btn-outline-primary\">' +\n '<i class=\"fas fa-database me-1\"></i>View Volumes' +\n '</a>' +\n '<a href=\"/files?collection=' + encodeURIComponent(collection.name) + '\" class=\"btn btn-outline-info\">' +\n '<i class=\"fas fa-folder me-1\"></i>Browse Files' +\n '</a>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '<div class=\"modal-footer\">' +\n '<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '</div>';\n \n // Remove existing modal if present\n const existingModal = document.getElementById('collectionDetailsModal');\n if (existingModal) {\n existingModal.remove();\n }\n \n // Add modal to body and show\n document.body.insertAdjacentHTML('beforeend', modalHtml);\n const modal = new bootstrap.Modal(document.getElementById('collectionDetailsModal'));\n modal.show();\n \n // Remove modal when hidden\n document.getElementById('collectionDetailsModal').addEventListener('hidden.bs.modal', function() {\n this.remove();\n });\n }\n \n function getDiskTypeBadgeColor(diskType) {\n switch(diskType.toLowerCase()) {\n case 'ssd':\n return 'primary';\n case 'hdd':\n case '':\n return 'secondary';\n default:\n return 'info';\n }\n }\n \n function formatBytes(bytes) {\n if (bytes === 0) return '0 Bytes';\n const k = 1024;\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n }\n \n function exportCollections() {\n // Simple CSV export of collections list\n const rows = Array.from(document.querySelectorAll('#collectionsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n name: cells[0].textContent.trim(),\n volumes: cells[1].textContent.trim(),\n files: cells[2].textContent.trim(),\n size: cells[3].textContent.trim(),\n diskTypes: cells[4].textContent.trim()\n };\n }\n return null;\n }).filter(row => row !== null);\n \n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Collection Name,Volumes,Files,Size,Disk Types\\n\" +\n rows.map(r => '\"' + r.name + '\",\"' + r.volumes + '\",\"' + r.files + '\",\"' + r.size + '\",\"' + r.diskTypes + '\"').join(\"\\n\");\n \n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"collections.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/cluster_filers.templ b/weed/admin/view/app/cluster_filers.templ index 6ed14ac6e..023fd4478 100644 --- a/weed/admin/view/app/cluster_filers.templ +++ b/weed/admin/view/app/cluster_filers.templ @@ -121,6 +121,62 @@ templ ClusterFilers(data dash.ClusterFilersData) { </div> </div> </div> + + <!-- JavaScript for cluster filers functionality --> + <script> + document.addEventListener('DOMContentLoaded', function() { + // Handle filer action buttons + document.addEventListener('click', function(e) { + const button = e.target.closest('[data-action]'); + if (!button) return; + + const action = button.getAttribute('data-action'); + const address = button.getAttribute('data-address'); + + if (!address) return; + + switch(action) { + case 'open-filer': + openFilerBrowser(address); + break; + } + }); + }); + + function openFilerBrowser(address) { + // Open file browser for specific filer + window.open('/files?filer=' + encodeURIComponent(address), '_blank'); + } + + function exportFilers() { + // Simple CSV export of filers list + const rows = Array.from(document.querySelectorAll('#filersTable tbody tr')).map(row => { + const cells = row.querySelectorAll('td'); + if (cells.length > 1) { + return { + address: cells[0].textContent.trim(), + version: cells[1].textContent.trim(), + datacenter: cells[2].textContent.trim(), + rack: cells[3].textContent.trim(), + created: cells[4].textContent.trim() + }; + } + return null; + }).filter(row => row !== null); + + const csvContent = "data:text/csv;charset=utf-8," + + "Address,Version,Data Center,Rack,Created At\n" + + rows.map(r => '"' + r.address + '","' + r.version + '","' + r.datacenter + '","' + r.rack + '","' + r.created + '"').join("\n"); + + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", "filers.csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + </script> }
\ No newline at end of file diff --git a/weed/admin/view/app/cluster_filers_templ.go b/weed/admin/view/app/cluster_filers_templ.go index ecc2d873e..69c489ce4 100644 --- a/weed/admin/view/app/cluster_filers_templ.go +++ b/weed/admin/view/app/cluster_filers_templ.go @@ -183,7 +183,7 @@ func ClusterFilers(data dash.ClusterFilersData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></div></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></div></div><!-- JavaScript for cluster filers functionality --><script>\n\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t// Handle filer action buttons\n\t\tdocument.addEventListener('click', function(e) {\n\t\t\tconst button = e.target.closest('[data-action]');\n\t\t\tif (!button) return;\n\t\t\t\n\t\t\tconst action = button.getAttribute('data-action');\n\t\t\tconst address = button.getAttribute('data-address');\n\t\t\t\n\t\t\tif (!address) return;\n\t\t\t\n\t\t\tswitch(action) {\n\t\t\t\tcase 'open-filer':\n\t\t\t\t\topenFilerBrowser(address);\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t});\n\t});\n\t\n\tfunction openFilerBrowser(address) {\n\t\t// Open file browser for specific filer\n\t\twindow.open('/files?filer=' + encodeURIComponent(address), '_blank');\n\t}\n\t\n\tfunction exportFilers() {\n\t\t// Simple CSV export of filers list\n\t\tconst rows = Array.from(document.querySelectorAll('#filersTable tbody tr')).map(row => {\n\t\t\tconst cells = row.querySelectorAll('td');\n\t\t\tif (cells.length > 1) {\n\t\t\t\treturn {\n\t\t\t\t\taddress: cells[0].textContent.trim(),\n\t\t\t\t\tversion: cells[1].textContent.trim(),\n\t\t\t\t\tdatacenter: cells[2].textContent.trim(),\n\t\t\t\t\track: cells[3].textContent.trim(),\n\t\t\t\t\tcreated: cells[4].textContent.trim()\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t}).filter(row => row !== null);\n\t\t\n\t\tconst csvContent = \"data:text/csv;charset=utf-8,\" + \n\t\t\t\"Address,Version,Data Center,Rack,Created At\\n\" +\n\t\t\trows.map(r => '\"' + r.address + '\",\"' + r.version + '\",\"' + r.datacenter + '\",\"' + r.rack + '\",\"' + r.created + '\"').join(\"\\n\");\n\t\t\n\t\tconst encodedUri = encodeURI(csvContent);\n\t\tconst link = document.createElement(\"a\");\n\t\tlink.setAttribute(\"href\", encodedUri);\n\t\tlink.setAttribute(\"download\", \"filers.csv\");\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n\t</script>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/cluster_masters.templ b/weed/admin/view/app/cluster_masters.templ index 9f6e2d0a9..6a53c5493 100644 --- a/weed/admin/view/app/cluster_masters.templ +++ b/weed/admin/view/app/cluster_masters.templ @@ -136,14 +136,15 @@ templ ClusterMasters(data dash.ClusterMastersData) { } </td> <td> - <div class="btn-group btn-group-sm"> - <button type="button" class="btn btn-outline-primary btn-sm" title="View Details"> - <i class="fas fa-eye"></i> - </button> - <button type="button" class="btn btn-outline-secondary btn-sm" title="Manage"> - <i class="fas fa-cog"></i> - </button> - </div> + <button type="button" + class="btn btn-outline-primary btn-sm" + title="View Details" + data-action="view-details" + data-address={master.Address} + data-leader={fmt.Sprintf("%t", master.IsLeader)} + data-suffrage={master.Suffrage}> + <i class="fas fa-eye"></i> + </button> </td> </tr> } @@ -170,6 +171,112 @@ templ ClusterMasters(data dash.ClusterMastersData) { </div> </div> </div> + + <!-- JavaScript for cluster masters functionality --> + <script> + document.addEventListener('DOMContentLoaded', function() { + // Handle master action buttons + document.addEventListener('click', function(e) { + const button = e.target.closest('[data-action]'); + if (!button) return; + + const action = button.getAttribute('data-action'); + const address = button.getAttribute('data-address'); + + if (!address) return; + + switch(action) { + case 'view-details': + const isLeader = button.getAttribute('data-leader') === 'true'; + const suffrage = button.getAttribute('data-suffrage'); + showMasterDetails(address, isLeader, suffrage); + break; + } + }); + }); + + function showMasterDetails(address, isLeader, suffrage) { + const modalHtml = '<div class="modal fade" id="masterDetailsModal" tabindex="-1">' + + '<div class="modal-dialog modal-lg">' + + '<div class="modal-content">' + + '<div class="modal-header">' + + '<h5 class="modal-title"><i class="fas fa-crown me-2"></i>Master Details: ' + address + '</h5>' + + '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' + + '</div>' + + '<div class="modal-body">' + + '<div class="row">' + + '<div class="col-md-6">' + + '<h6 class="text-primary"><i class="fas fa-info-circle me-1"></i>Basic Information</h6>' + + '<table class="table table-sm">' + + '<tr><td><strong>Address:</strong></td><td>' + address + '</td></tr>' + + '<tr><td><strong>Role:</strong></td><td>' + + (isLeader ? '<span class="badge bg-warning text-dark"><i class="fas fa-star me-1"></i>Leader</span>' : + '<span class="badge bg-secondary">Follower</span>') + '</td></tr>' + + '<tr><td><strong>Suffrage:</strong></td><td>' + (suffrage || 'N/A') + '</td></tr>' + + '<tr><td><strong>Status:</strong></td><td><span class="badge bg-success">Active</span></td></tr>' + + '</table>' + + '</div>' + + '<div class="col-md-6">' + + '<h6 class="text-primary"><i class="fas fa-link me-1"></i>Quick Actions</h6>' + + '<div class="d-grid gap-2">' + + '<a href="http://' + address + '" target="_blank" class="btn btn-outline-primary">' + + '<i class="fas fa-external-link-alt me-1"></i>Open Master UI' + + '</a>' + + '</div>' + + '</div>' + + '</div>' + + '</div>' + + '<div class="modal-footer">' + + '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' + + '</div>' + + '</div>' + + '</div>' + + '</div>'; + + // Remove existing modal if present + const existingModal = document.getElementById('masterDetailsModal'); + if (existingModal) { + existingModal.remove(); + } + + // Add modal to body and show + document.body.insertAdjacentHTML('beforeend', modalHtml); + const modal = new bootstrap.Modal(document.getElementById('masterDetailsModal')); + modal.show(); + + // Remove modal when hidden + document.getElementById('masterDetailsModal').addEventListener('hidden.bs.modal', function() { + this.remove(); + }); + } + + function exportMasters() { + // Simple CSV export of masters list + const rows = Array.from(document.querySelectorAll('#mastersTable tbody tr')).map(row => { + const cells = row.querySelectorAll('td'); + if (cells.length > 1) { + return { + address: cells[0].textContent.trim(), + role: cells[1].textContent.trim(), + suffrage: cells[2].textContent.trim() + }; + } + return null; + }).filter(row => row !== null); + + const csvContent = "data:text/csv;charset=utf-8," + + "Address,Role,Suffrage\n" + + rows.map(r => '"' + r.address + '","' + r.role + '","' + r.suffrage + '"').join("\n"); + + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", "masters.csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + </script> }
\ No newline at end of file diff --git a/weed/admin/view/app/cluster_masters_templ.go b/weed/admin/view/app/cluster_masters_templ.go index 951db551e..e0be75cc4 100644 --- a/weed/admin/view/app/cluster_masters_templ.go +++ b/weed/admin/view/app/cluster_masters_templ.go @@ -154,35 +154,74 @@ func ClusterMasters(data dash.ClusterMastersData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Manage\"><i class=\"fas fa-cog\"></i></button></div></td></tr>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\" data-action=\"view-details\" data-address=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(master.Address) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 143, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-leader=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", master.IsLeader)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 144, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-suffrage=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(master.Suffrage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 145, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><i class=\"fas fa-eye\"></i></button></td></tr>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</tbody></table></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</tbody></table></div>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"text-center py-5\"><i class=\"fas fa-crown fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Masters Found</h5><p class=\"text-muted\">No master servers are currently available in the cluster.</p></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"text-center py-5\"><i class=\"fas fa-crown fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Masters Found</h5><p class=\"text-muted\">No master servers are currently available in the cluster.</p></div>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, 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/cluster_masters.templ`, Line: 168, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 169, Col: 67} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</small></div></div></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</small></div></div></div><!-- JavaScript for cluster masters functionality --><script>\n\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t// Handle master action buttons\n\t\tdocument.addEventListener('click', function(e) {\n\t\t\tconst button = e.target.closest('[data-action]');\n\t\t\tif (!button) return;\n\t\t\t\n\t\t\tconst action = button.getAttribute('data-action');\n\t\t\tconst address = button.getAttribute('data-address');\n\t\t\t\n\t\t\tif (!address) return;\n\t\t\t\n\t\t\tswitch(action) {\n\t\t\t\tcase 'view-details':\n\t\t\t\t\tconst isLeader = button.getAttribute('data-leader') === 'true';\n\t\t\t\t\tconst suffrage = button.getAttribute('data-suffrage');\n\t\t\t\t\tshowMasterDetails(address, isLeader, suffrage);\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t});\n\t});\n\t\n\tfunction showMasterDetails(address, isLeader, suffrage) {\n\t\tconst modalHtml = '<div class=\"modal fade\" id=\"masterDetailsModal\" tabindex=\"-1\">' +\n\t\t\t'<div class=\"modal-dialog modal-lg\">' +\n\t\t\t'<div class=\"modal-content\">' +\n\t\t\t'<div class=\"modal-header\">' +\n\t\t\t'<h5 class=\"modal-title\"><i class=\"fas fa-crown me-2\"></i>Master Details: ' + address + '</h5>' +\n\t\t\t'<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-body\">' +\n\t\t\t'<div class=\"row\">' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-info-circle me-1\"></i>Basic Information</h6>' +\n\t\t\t'<table class=\"table table-sm\">' +\n\t\t\t'<tr><td><strong>Address:</strong></td><td>' + address + '</td></tr>' +\n\t\t\t'<tr><td><strong>Role:</strong></td><td>' + \n\t\t\t(isLeader ? '<span class=\"badge bg-warning text-dark\"><i class=\"fas fa-star me-1\"></i>Leader</span>' : \n\t\t\t'<span class=\"badge bg-secondary\">Follower</span>') + '</td></tr>' +\n\t\t\t'<tr><td><strong>Suffrage:</strong></td><td>' + (suffrage || 'N/A') + '</td></tr>' +\n\t\t\t'<tr><td><strong>Status:</strong></td><td><span class=\"badge bg-success\">Active</span></td></tr>' +\n\t\t\t'</table>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-link me-1\"></i>Quick Actions</h6>' +\n\t\t\t'<div class=\"d-grid gap-2\">' +\n\t\t\t'<a href=\"http://' + address + '\" target=\"_blank\" class=\"btn btn-outline-primary\">' +\n\t\t\t'<i class=\"fas fa-external-link-alt me-1\"></i>Open Master UI' +\n\t\t\t'</a>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-footer\">' +\n\t\t\t'<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Remove existing modal if present\n\t\tconst existingModal = document.getElementById('masterDetailsModal');\n\t\tif (existingModal) {\n\t\t\texistingModal.remove();\n\t\t}\n\t\t\n\t\t// Add modal to body and show\n\t\tdocument.body.insertAdjacentHTML('beforeend', modalHtml);\n\t\tconst modal = new bootstrap.Modal(document.getElementById('masterDetailsModal'));\n\t\tmodal.show();\n\t\t\n\t\t// Remove modal when hidden\n\t\tdocument.getElementById('masterDetailsModal').addEventListener('hidden.bs.modal', function() {\n\t\t\tthis.remove();\n\t\t});\n\t}\n\t\n\tfunction exportMasters() {\n\t\t// Simple CSV export of masters list\n\t\tconst rows = Array.from(document.querySelectorAll('#mastersTable tbody tr')).map(row => {\n\t\t\tconst cells = row.querySelectorAll('td');\n\t\t\tif (cells.length > 1) {\n\t\t\t\treturn {\n\t\t\t\t\taddress: cells[0].textContent.trim(),\n\t\t\t\t\trole: cells[1].textContent.trim(),\n\t\t\t\t\tsuffrage: cells[2].textContent.trim()\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t}).filter(row => row !== null);\n\t\t\n\t\tconst csvContent = \"data:text/csv;charset=utf-8,\" + \n\t\t\t\"Address,Role,Suffrage\\n\" +\n\t\t\trows.map(r => '\"' + r.address + '\",\"' + r.role + '\",\"' + r.suffrage + '\"').join(\"\\n\");\n\t\t\n\t\tconst encodedUri = encodeURI(csvContent);\n\t\tconst link = document.createElement(\"a\");\n\t\tlink.setAttribute(\"href\", encodedUri);\n\t\tlink.setAttribute(\"download\", \"masters.csv\");\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n\t</script>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/cluster_volume_servers.templ b/weed/admin/view/app/cluster_volume_servers.templ index 20c661d40..f6b737a57 100644 --- a/weed/admin/view/app/cluster_volume_servers.templ +++ b/weed/admin/view/app/cluster_volume_servers.templ @@ -148,16 +148,22 @@ templ ClusterVolumeServers(data dash.ClusterVolumeServersData) { </div> </td> <td> - <div class="btn-group btn-group-sm"> - <button type="button" class="btn btn-outline-primary btn-sm" - title="View Details"> - <i class="fas fa-eye"></i> - </button> - <button type="button" class="btn btn-outline-secondary btn-sm" - title="Manage"> - <i class="fas fa-cog"></i> - </button> - </div> + <button type="button" + class="btn btn-outline-primary btn-sm" + title="View Details" + data-action="view-details" + data-id={host.ID} + data-address={host.Address} + data-public-url={host.PublicURL} + data-datacenter={host.DataCenter} + data-rack={host.Rack} + data-volumes={fmt.Sprintf("%d", host.Volumes)} + data-max-volumes={fmt.Sprintf("%d", host.MaxVolumes)} + data-disk-usage={fmt.Sprintf("%d", host.DiskUsage)} + data-disk-capacity={fmt.Sprintf("%d", host.DiskCapacity)} + data-last-heartbeat={host.LastHeartbeat.Format("2006-01-02 15:04:05")}> + <i class="fas fa-eye"></i> + </button> </td> </tr> } @@ -184,6 +190,161 @@ templ ClusterVolumeServers(data dash.ClusterVolumeServersData) { </div> </div> </div> + + <!-- JavaScript for cluster volume servers functionality --> + <script> + document.addEventListener('DOMContentLoaded', function() { + // Handle volume server action buttons + document.addEventListener('click', function(e) { + const button = e.target.closest('[data-action]'); + if (!button) return; + + const action = button.getAttribute('data-action'); + + switch(action) { + case 'view-details': + const serverData = { + id: button.getAttribute('data-id'), + address: button.getAttribute('data-address'), + publicUrl: button.getAttribute('data-public-url'), + datacenter: button.getAttribute('data-datacenter'), + rack: button.getAttribute('data-rack'), + volumes: parseInt(button.getAttribute('data-volumes')), + maxVolumes: parseInt(button.getAttribute('data-max-volumes')), + diskUsage: parseInt(button.getAttribute('data-disk-usage')), + diskCapacity: parseInt(button.getAttribute('data-disk-capacity')), + lastHeartbeat: button.getAttribute('data-last-heartbeat') + }; + showVolumeServerDetails(serverData); + break; + } + }); + }); + + function showVolumeServerDetails(server) { + const volumePercent = server.maxVolumes > 0 ? Math.round((server.volumes / server.maxVolumes) * 100) : 0; + const diskPercent = server.diskCapacity > 0 ? Math.round((server.diskUsage / server.diskCapacity) * 100) : 0; + + const modalHtml = '<div class="modal fade" id="volumeServerDetailsModal" tabindex="-1">' + + '<div class="modal-dialog modal-lg">' + + '<div class="modal-content">' + + '<div class="modal-header">' + + '<h5 class="modal-title"><i class="fas fa-server me-2"></i>Volume Server Details: ' + server.address + '</h5>' + + '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' + + '</div>' + + '<div class="modal-body">' + + '<div class="row">' + + '<div class="col-md-6">' + + '<h6 class="text-primary"><i class="fas fa-info-circle me-1"></i>Basic Information</h6>' + + '<table class="table table-sm">' + + '<tr><td><strong>Server ID:</strong></td><td><code>' + server.id + '</code></td></tr>' + + '<tr><td><strong>Address:</strong></td><td>' + server.address + '</td></tr>' + + '<tr><td><strong>Public URL:</strong></td><td>' + server.publicUrl + '</td></tr>' + + '<tr><td><strong>Data Center:</strong></td><td><span class="badge bg-light text-dark">' + server.datacenter + '</span></td></tr>' + + '<tr><td><strong>Rack:</strong></td><td><span class="badge bg-light text-dark">' + server.rack + '</span></td></tr>' + + '<tr><td><strong>Last Heartbeat:</strong></td><td>' + server.lastHeartbeat + '</td></tr>' + + '</table>' + + '</div>' + + '<div class="col-md-6">' + + '<h6 class="text-primary"><i class="fas fa-chart-bar me-1"></i>Usage Statistics</h6>' + + '<table class="table table-sm">' + + '<tr><td><strong>Volumes:</strong></td><td>' + + '<div class="d-flex align-items-center">' + + '<div class="progress me-2" style="width: 100px; height: 20px;">' + + '<div class="progress-bar" role="progressbar" style="width: ' + volumePercent + '%"></div>' + + '</div>' + + '<span>' + server.volumes + '/' + server.maxVolumes + ' (' + volumePercent + '%)</span>' + + '</div>' + + '</td></tr>' + + '<tr><td><strong>Disk Usage:</strong></td><td>' + + '<div class="d-flex align-items-center">' + + '<div class="progress me-2" style="width: 100px; height: 20px;">' + + '<div class="progress-bar" role="progressbar" style="width: ' + diskPercent + '%"></div>' + + '</div>' + + '<span>' + formatBytes(server.diskUsage) + '/' + formatBytes(server.diskCapacity) + ' (' + diskPercent + '%)</span>' + + '</div>' + + '</td></tr>' + + '<tr><td><strong>Available Space:</strong></td><td>' + formatBytes(server.diskCapacity - server.diskUsage) + '</td></tr>' + + '</table>' + + '</div>' + + '</div>' + + '<div class="row mt-3">' + + '<div class="col-12">' + + '<h6 class="text-primary"><i class="fas fa-link me-1"></i>Quick Actions</h6>' + + '<div class="d-grid gap-2 d-md-flex">' + + '<a href="http://' + server.publicUrl + '/ui/index.html" target="_blank" class="btn btn-outline-primary">' + + '<i class="fas fa-external-link-alt me-1"></i>Open Volume Server UI' + + '</a>' + + '<a href="/cluster/volumes?server=' + encodeURIComponent(server.address) + '" class="btn btn-outline-info">' + + '<i class="fas fa-database me-1"></i>View Volumes' + + '</a>' + + '</div>' + + '</div>' + + '</div>' + + '</div>' + + '<div class="modal-footer">' + + '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' + + '</div>' + + '</div>' + + '</div>' + + '</div>'; + + // Remove existing modal if present + const existingModal = document.getElementById('volumeServerDetailsModal'); + if (existingModal) { + existingModal.remove(); + } + + // Add modal to body and show + document.body.insertAdjacentHTML('beforeend', modalHtml); + const modal = new bootstrap.Modal(document.getElementById('volumeServerDetailsModal')); + modal.show(); + + // Remove modal when hidden + document.getElementById('volumeServerDetailsModal').addEventListener('hidden.bs.modal', function() { + this.remove(); + }); + } + + function formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + function exportVolumeServers() { + // Simple CSV export of volume servers list + const rows = Array.from(document.querySelectorAll('#hostsTable tbody tr')).map(row => { + const cells = row.querySelectorAll('td'); + if (cells.length > 1) { + return { + id: cells[0].textContent.trim(), + address: cells[1].textContent.trim(), + datacenter: cells[2].textContent.trim(), + rack: cells[3].textContent.trim(), + volumes: cells[4].textContent.trim(), + capacity: cells[5].textContent.trim(), + usage: cells[6].textContent.trim() + }; + } + return null; + }).filter(row => row !== null); + + const csvContent = "data:text/csv;charset=utf-8," + + "Server ID,Address,Data Center,Rack,Volumes,Capacity,Usage\n" + + rows.map(r => '"' + r.id + '","' + r.address + '","' + r.datacenter + '","' + r.rack + '","' + r.volumes + '","' + r.capacity + '","' + r.usage + '"').join("\n"); + + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", "volume_servers.csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + </script> }
\ No newline at end of file diff --git a/weed/admin/view/app/cluster_volume_servers_templ.go b/weed/admin/view/app/cluster_volume_servers_templ.go index 1bd439974..094774c7a 100644 --- a/weed/admin/view/app/cluster_volume_servers_templ.go +++ b/weed/admin/view/app/cluster_volume_servers_templ.go @@ -213,35 +213,165 @@ func ClusterVolumeServers(data dash.ClusterVolumeServersData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Manage\"><i class=\"fas fa-cog\"></i></button></div></td></tr>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></td><td><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\" data-action=\"view-details\" data-id=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(host.ID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 155, Col: 68} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" data-address=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(host.Address) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 156, Col: 78} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-public-url=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(host.PublicURL) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 157, Col: 83} + } + _, 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, 19, "\" data-datacenter=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(host.DataCenter) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 158, Col: 84} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" data-rack=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(host.Rack) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 159, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-volumes=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", host.Volumes)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 160, Col: 97} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-max-volumes=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", host.MaxVolumes)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 161, Col: 104} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-disk-usage=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", host.DiskUsage)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 162, Col: 102} + } + _, 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, 24, "\" data-disk-capacity=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", host.DiskCapacity)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 163, Col: 108} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-last-heartbeat=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(host.LastHeartbeat.Format("2006-01-02 15:04:05")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 164, Col: 121} + } + _, 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, 26, "\"><i class=\"fas fa-eye\"></i></button></td></tr>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</tbody></table></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</tbody></table></div>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"text-center py-5\"><i class=\"fas fa-server fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Volume Servers Found</h5><p class=\"text-muted\">No volume servers are currently available in the cluster.</p></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"text-center py-5\"><i class=\"fas fa-server fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Volume Servers Found</h5><p class=\"text-muted\">No volume servers are currently available in the cluster.</p></div>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, 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/cluster_volume_servers.templ`, Line: 182, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 188, Col: 81} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</small></div></div></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</small></div></div></div><!-- JavaScript for cluster volume servers functionality --><script>\n document.addEventListener('DOMContentLoaded', function() {\n // Handle volume server action buttons\n document.addEventListener('click', function(e) {\n const button = e.target.closest('[data-action]');\n if (!button) return;\n \n const action = button.getAttribute('data-action');\n \n switch(action) {\n case 'view-details':\n const serverData = {\n id: button.getAttribute('data-id'),\n address: button.getAttribute('data-address'),\n publicUrl: button.getAttribute('data-public-url'),\n datacenter: button.getAttribute('data-datacenter'),\n rack: button.getAttribute('data-rack'),\n volumes: parseInt(button.getAttribute('data-volumes')),\n maxVolumes: parseInt(button.getAttribute('data-max-volumes')),\n diskUsage: parseInt(button.getAttribute('data-disk-usage')),\n diskCapacity: parseInt(button.getAttribute('data-disk-capacity')),\n lastHeartbeat: button.getAttribute('data-last-heartbeat')\n };\n showVolumeServerDetails(serverData);\n break;\n }\n });\n });\n \n function showVolumeServerDetails(server) {\n const volumePercent = server.maxVolumes > 0 ? Math.round((server.volumes / server.maxVolumes) * 100) : 0;\n const diskPercent = server.diskCapacity > 0 ? Math.round((server.diskUsage / server.diskCapacity) * 100) : 0;\n \n const modalHtml = '<div class=\"modal fade\" id=\"volumeServerDetailsModal\" tabindex=\"-1\">' +\n '<div class=\"modal-dialog modal-lg\">' +\n '<div class=\"modal-content\">' +\n '<div class=\"modal-header\">' +\n '<h5 class=\"modal-title\"><i class=\"fas fa-server me-2\"></i>Volume Server Details: ' + server.address + '</h5>' +\n '<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n '</div>' +\n '<div class=\"modal-body\">' +\n '<div class=\"row\">' +\n '<div class=\"col-md-6\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-info-circle me-1\"></i>Basic Information</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr><td><strong>Server ID:</strong></td><td><code>' + server.id + '</code></td></tr>' +\n '<tr><td><strong>Address:</strong></td><td>' + server.address + '</td></tr>' +\n '<tr><td><strong>Public URL:</strong></td><td>' + server.publicUrl + '</td></tr>' +\n '<tr><td><strong>Data Center:</strong></td><td><span class=\"badge bg-light text-dark\">' + server.datacenter + '</span></td></tr>' +\n '<tr><td><strong>Rack:</strong></td><td><span class=\"badge bg-light text-dark\">' + server.rack + '</span></td></tr>' +\n '<tr><td><strong>Last Heartbeat:</strong></td><td>' + server.lastHeartbeat + '</td></tr>' +\n '</table>' +\n '</div>' +\n '<div class=\"col-md-6\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-chart-bar me-1\"></i>Usage Statistics</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr><td><strong>Volumes:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<div class=\"progress me-2\" style=\"width: 100px; height: 20px;\">' +\n '<div class=\"progress-bar\" role=\"progressbar\" style=\"width: ' + volumePercent + '%\"></div>' +\n '</div>' +\n '<span>' + server.volumes + '/' + server.maxVolumes + ' (' + volumePercent + '%)</span>' +\n '</div>' +\n '</td></tr>' +\n '<tr><td><strong>Disk Usage:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<div class=\"progress me-2\" style=\"width: 100px; height: 20px;\">' +\n '<div class=\"progress-bar\" role=\"progressbar\" style=\"width: ' + diskPercent + '%\"></div>' +\n '</div>' +\n '<span>' + formatBytes(server.diskUsage) + '/' + formatBytes(server.diskCapacity) + ' (' + diskPercent + '%)</span>' +\n '</div>' +\n '</td></tr>' +\n '<tr><td><strong>Available Space:</strong></td><td>' + formatBytes(server.diskCapacity - server.diskUsage) + '</td></tr>' +\n '</table>' +\n '</div>' +\n '</div>' +\n '<div class=\"row mt-3\">' +\n '<div class=\"col-12\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-link me-1\"></i>Quick Actions</h6>' +\n '<div class=\"d-grid gap-2 d-md-flex\">' +\n '<a href=\"http://' + server.publicUrl + '/ui/index.html\" target=\"_blank\" class=\"btn btn-outline-primary\">' +\n '<i class=\"fas fa-external-link-alt me-1\"></i>Open Volume Server UI' +\n '</a>' +\n '<a href=\"/cluster/volumes?server=' + encodeURIComponent(server.address) + '\" class=\"btn btn-outline-info\">' +\n '<i class=\"fas fa-database me-1\"></i>View Volumes' +\n '</a>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '<div class=\"modal-footer\">' +\n '<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '</div>';\n \n // Remove existing modal if present\n const existingModal = document.getElementById('volumeServerDetailsModal');\n if (existingModal) {\n existingModal.remove();\n }\n \n // Add modal to body and show\n document.body.insertAdjacentHTML('beforeend', modalHtml);\n const modal = new bootstrap.Modal(document.getElementById('volumeServerDetailsModal'));\n modal.show();\n \n // Remove modal when hidden\n document.getElementById('volumeServerDetailsModal').addEventListener('hidden.bs.modal', function() {\n this.remove();\n });\n }\n \n function formatBytes(bytes) {\n if (bytes === 0) return '0 Bytes';\n const k = 1024;\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n }\n \n function exportVolumeServers() {\n // Simple CSV export of volume servers list\n const rows = Array.from(document.querySelectorAll('#hostsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n id: cells[0].textContent.trim(),\n address: cells[1].textContent.trim(),\n datacenter: cells[2].textContent.trim(),\n rack: cells[3].textContent.trim(),\n volumes: cells[4].textContent.trim(),\n capacity: cells[5].textContent.trim(),\n usage: cells[6].textContent.trim()\n };\n }\n return null;\n }).filter(row => row !== null);\n \n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Server ID,Address,Data Center,Rack,Volumes,Capacity,Usage\\n\" +\n rows.map(r => '\"' + r.id + '\",\"' + r.address + '\",\"' + r.datacenter + '\",\"' + r.rack + '\",\"' + r.volumes + '\",\"' + r.capacity + '\",\"' + r.usage + '\"').join(\"\\n\");\n \n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"volume_servers.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/file_browser.templ b/weed/admin/view/app/file_browser.templ index a1e00555f..83db7df0f 100644 --- a/weed/admin/view/app/file_browser.templ +++ b/weed/admin/view/app/file_browser.templ @@ -228,7 +228,7 @@ templ FileBrowser(data dash.FileBrowserData) { } </td> <td> - <code class="small">{ entry.Mode }</code> + <code class="small permissions-display" data-mode={ entry.Mode } data-is-directory={ fmt.Sprintf("%t", entry.IsDirectory) }>{ entry.Mode }</code> </td> <td> <div class="btn-group btn-group-sm" role="group"> @@ -356,6 +356,380 @@ templ FileBrowser(data dash.FileBrowserData) { </div> </div> </div> + + <!-- JavaScript for file browser functionality --> + <script> + document.addEventListener('DOMContentLoaded', function() { + // Format permissions in the main table + document.querySelectorAll('.permissions-display').forEach(element => { + const mode = element.getAttribute('data-mode'); + const isDirectory = element.getAttribute('data-is-directory') === 'true'; + if (mode) { + element.textContent = formatPermissions(mode, isDirectory); + } + }); + + // Handle file browser action buttons (download, view, properties, delete) + document.addEventListener('click', function(e) { + const button = e.target.closest('[data-action]'); + if (!button) return; + + const action = button.getAttribute('data-action'); + const path = button.getAttribute('data-path'); + + if (!path) return; + + switch(action) { + case 'download': + downloadFile(path); + break; + case 'view': + viewFile(path); + break; + case 'properties': + showFileProperties(path); + break; + case 'delete': + if (confirm('Are you sure you want to delete "' + path + '"?')) { + deleteFile(path); + } + break; + } + }); + + // Initialize file manager event handlers from admin.js + if (typeof setupFileManagerEventHandlers === 'function') { + setupFileManagerEventHandlers(); + } + }); + + // File browser specific functions + function downloadFile(path) { + // Open download URL in new tab + window.open('/api/files/download?path=' + encodeURIComponent(path), '_blank'); + } + + function viewFile(path) { + // Open file viewer in new tab + window.open('/api/files/view?path=' + encodeURIComponent(path), '_blank'); + } + + function showFileProperties(path) { + // Fetch file properties and show in modal + fetch('/api/files/properties?path=' + encodeURIComponent(path)) + .then(response => response.json()) + .then(data => { + if (data.error) { + alert('Error loading file properties: ' + data.error); + } else { + displayFileProperties(data); + } + }) + .catch(error => { + console.error('Error fetching file properties:', error); + alert('Error loading file properties: ' + error.message); + }); + } + + function displayFileProperties(data) { + // Create a comprehensive modal for file properties + const modalHtml = '<div class="modal fade" id="filePropertiesModal" tabindex="-1">' + + '<div class="modal-dialog modal-lg">' + + '<div class="modal-content">' + + '<div class="modal-header">' + + '<h5 class="modal-title"><i class="fas fa-info-circle me-2"></i>Properties: ' + (data.name || 'Unknown') + '</h5>' + + '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' + + '</div>' + + '<div class="modal-body">' + + createFilePropertiesContent(data) + + '</div>' + + '<div class="modal-footer">' + + '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' + + '</div>' + + '</div>' + + '</div>' + + '</div>'; + + // Remove existing modal if present + const existingModal = document.getElementById('filePropertiesModal'); + if (existingModal) { + existingModal.remove(); + } + + // Add modal to body and show + document.body.insertAdjacentHTML('beforeend', modalHtml); + const modal = new bootstrap.Modal(document.getElementById('filePropertiesModal')); + modal.show(); + + // Remove modal when hidden + document.getElementById('filePropertiesModal').addEventListener('hidden.bs.modal', function() { + this.remove(); + }); + } + + function createFilePropertiesContent(data) { + let html = '<div class="row">' + + '<div class="col-12">' + + '<h6 class="text-primary"><i class="fas fa-file me-1"></i>Basic Information</h6>' + + '<table class="table table-sm">' + + '<tr><td style="width: 120px;"><strong>Name:</strong></td><td>' + (data.name || 'N/A') + '</td></tr>' + + '<tr><td><strong>Full Path:</strong></td><td><code class="text-break">' + (data.full_path || 'N/A') + '</code></td></tr>' + + '<tr><td><strong>Type:</strong></td><td>' + (data.is_directory ? 'Directory' : 'File') + '</td></tr>'; + + if (!data.is_directory) { + html += '<tr><td><strong>Size:</strong></td><td>' + (data.size_formatted || (data.size ? formatBytes(data.size) : 'N/A')) + '</td></tr>' + + '<tr><td><strong>MIME Type:</strong></td><td>' + (data.mime_type || 'N/A') + '</td></tr>'; + } + + html += '</table>' + + '</div>' + + '</div>' + + '<div class="row">' + + '<div class="col-md-6">' + + '<h6 class="text-primary"><i class="fas fa-clock me-1"></i>Timestamps</h6>' + + '<table class="table table-sm">'; + + if (data.modified_time) { + html += '<tr><td><strong>Modified:</strong></td><td>' + data.modified_time + '</td></tr>'; + } + if (data.created_time) { + html += '<tr><td><strong>Created:</strong></td><td>' + data.created_time + '</td></tr>'; + } + + html += '</table>' + + '</div>' + + '<div class="col-md-6">' + + '<h6 class="text-primary"><i class="fas fa-shield-alt me-1"></i>Permissions</h6>' + + '<table class="table table-sm">'; + + if (data.file_mode) { + const rwxPermissions = formatPermissions(data.file_mode, data.is_directory); + html += '<tr><td><strong>Permissions:</strong></td><td><code>' + rwxPermissions + '</code></td></tr>'; + } + if (data.uid !== undefined) { + html += '<tr><td><strong>User ID:</strong></td><td>' + data.uid + '</td></tr>'; + } + if (data.gid !== undefined) { + html += '<tr><td><strong>Group ID:</strong></td><td>' + data.gid + '</td></tr>'; + } + + html += '</table>' + + '</div>' + + '</div>'; + + // Add advanced info + html += '<div class="row">' + + '<div class="col-12">' + + '<h6 class="text-primary"><i class="fas fa-cog me-1"></i>Advanced</h6>' + + '<table class="table table-sm">'; + + if (data.chunk_count) { + html += '<tr><td style="width: 120px;"><strong>Chunks:</strong></td><td>' + data.chunk_count + '</td></tr>'; + } + if (data.ttl_formatted) { + html += '<tr><td><strong>TTL:</strong></td><td>' + data.ttl_formatted + '</td></tr>'; + } + + html += '</table>' + + '</div>' + + '</div>'; + + // Add chunk details if available (show top 5) + if (data.chunks && data.chunks.length > 0) { + const chunksToShow = data.chunks.slice(0, 5); + html += '<div class="row mt-3">' + + '<div class="col-12">' + + '<h6 class="text-primary"><i class="fas fa-puzzle-piece me-1"></i>Chunk Details' + + (data.chunk_count > 5 ? ' (Top 5 of ' + data.chunk_count + ')' : ' (' + data.chunk_count + ')') + + '</h6>' + + '<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">' + + '<table class="table table-sm table-striped">' + + '<thead>' + + '<tr>' + + '<th>File ID</th>' + + '<th>Offset</th>' + + '<th>Size</th>' + + '<th>ETag</th>' + + '</tr>' + + '</thead>' + + '<tbody>'; + + chunksToShow.forEach(chunk => { + html += '<tr>' + + '<td><code class="small">' + (chunk.file_id || 'N/A') + '</code></td>' + + '<td>' + formatBytes(chunk.offset || 0) + '</td>' + + '<td>' + formatBytes(chunk.size || 0) + '</td>' + + '<td><code class="small">' + (chunk.e_tag || 'N/A') + '</code></td>' + + '</tr>'; + }); + + html += '</tbody>' + + '</table>' + + '</div>' + + '</div>' + + '</div>'; + } + + // Add extended attributes if present + if (data.extended && Object.keys(data.extended).length > 0) { + html += '<div class="row">' + + '<div class="col-12">' + + '<h6 class="text-primary"><i class="fas fa-tags me-1"></i>Extended Attributes</h6>' + + '<table class="table table-sm">'; + + for (const [key, value] of Object.entries(data.extended)) { + html += '<tr><td><strong>' + key + ':</strong></td><td>' + value + '</td></tr>'; + } + + html += '</table>' + + '</div>' + + '</div>'; + } + + return html; + } + + function uploadFile() { + const modal = new bootstrap.Modal(document.getElementById('uploadFileModal')); + modal.show(); + } + + function toggleSelectAll() { + const selectAllCheckbox = document.getElementById('selectAll'); + const checkboxes = document.querySelectorAll('.file-checkbox'); + + checkboxes.forEach(checkbox => { + checkbox.checked = selectAllCheckbox.checked; + }); + + updateDeleteSelectedButton(); + } + + function updateDeleteSelectedButton() { + const checkboxes = document.querySelectorAll('.file-checkbox:checked'); + const deleteBtn = document.getElementById('deleteSelectedBtn'); + + if (checkboxes.length > 0) { + deleteBtn.style.display = 'inline-block'; + } else { + deleteBtn.style.display = 'none'; + } + } + + // Helper function to format bytes + function formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + // Helper function to format permissions in rwxrwxrwx format + function formatPermissions(mode, isDirectory) { + // Check if mode is already in rwxrwxrwx format (e.g., "drwxr-xr-x" or "-rw-r--r--") + if (mode && (mode.startsWith('d') || mode.startsWith('-') || mode.startsWith('l')) && mode.length === 10) { + return mode; // Already formatted + } + + // Convert to number - could be octal string or decimal + let permissions; + if (typeof mode === 'string') { + // Try parsing as octal first, then decimal + if (mode.startsWith('0') && mode.length <= 4) { + permissions = parseInt(mode, 8); + } else { + permissions = parseInt(mode, 10); + } + } else { + permissions = parseInt(mode, 10); + } + + if (isNaN(permissions)) { + return isDirectory ? 'drwxr-xr-x' : '-rw-r--r--'; // Default fallback + } + + // Handle Go's os.ModeDir conversion + // Go's os.ModeDir is 0x80000000 (2147483648), but Unix S_IFDIR is 0o40000 (16384) + let fileType = '-'; + + // Check for Go's os.ModeDir flag + if (permissions & 0x80000000) { + fileType = 'd'; + } + // Check for standard Unix file type bits + else if ((permissions & 0xF000) === 0x4000) { // S_IFDIR (0o40000) + fileType = 'd'; + } else if ((permissions & 0xF000) === 0x8000) { // S_IFREG (0o100000) + fileType = '-'; + } else if ((permissions & 0xF000) === 0xA000) { // S_IFLNK (0o120000) + fileType = 'l'; + } else if ((permissions & 0xF000) === 0x2000) { // S_IFCHR (0o020000) + fileType = 'c'; + } else if ((permissions & 0xF000) === 0x6000) { // S_IFBLK (0o060000) + fileType = 'b'; + } else if ((permissions & 0xF000) === 0x1000) { // S_IFIFO (0o010000) + fileType = 'p'; + } else if ((permissions & 0xF000) === 0xC000) { // S_IFSOCK (0o140000) + fileType = 's'; + } + // Fallback to isDirectory parameter if file type detection fails + else if (isDirectory) { + fileType = 'd'; + } + + // Permission bits (always use the lower 12 bits for permissions) + const owner = (permissions >> 6) & 7; + const group = (permissions >> 3) & 7; + const others = permissions & 7; + + // Convert number to rwx format + function numToRwx(num) { + const r = (num & 4) ? 'r' : '-'; + const w = (num & 2) ? 'w' : '-'; + const x = (num & 1) ? 'x' : '-'; + return r + w + x; + } + + return fileType + numToRwx(owner) + numToRwx(group) + numToRwx(others); + } + + function exportFileList() { + // Simple CSV export of file list + const rows = Array.from(document.querySelectorAll('#fileTable tbody tr')).map(row => { + const cells = row.querySelectorAll('td'); + if (cells.length > 1) { + return { + name: cells[1].textContent.trim(), + size: cells[2].textContent.trim(), + type: cells[3].textContent.trim(), + modified: cells[4].textContent.trim(), + permissions: cells[5].textContent.trim() + }; + } + return null; + }).filter(row => row !== null); + + const csvContent = "data:text/csv;charset=utf-8," + + "Name,Size,Type,Modified,Permissions\n" + + rows.map(r => '"' + r.name + '","' + r.size + '","' + r.type + '","' + r.modified + '","' + r.permissions + '"').join("\n"); + + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", "files.csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + // Handle file checkbox changes + document.addEventListener('change', function(e) { + if (e.target.classList.contains('file-checkbox')) { + updateDeleteSelectedButton(); + } + }); + </script> } func countDirectories(entries []dash.FileEntry) int { diff --git a/weed/admin/view/app/file_browser_templ.go b/weed/admin/view/app/file_browser_templ.go index c4367e82d..ca1db51b2 100644 --- a/weed/admin/view/app/file_browser_templ.go +++ b/weed/admin/view/app/file_browser_templ.go @@ -392,136 +392,162 @@ func FileBrowser(data dash.FileBrowserData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</td><td><code class=\"small\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</td><td><code class=\"small permissions-display\" data-mode=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Mode) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</code></td><td><div class=\"btn-group btn-group-sm\" role=\"group\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" data-is-directory=\"") + 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", entry.IsDirectory)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 131} + } + _, 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, 42, "\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Mode) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 146} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</code></td><td><div class=\"btn-group btn-group-sm\" role=\"group\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !entry.IsDirectory { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"Download\" data-action=\"download\" data-path=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"Download\" data-action=\"download\" data-path=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath) + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 236, Col: 139} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, 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, 43, "\"><i class=\"fas fa-download\"></i></button> <button type=\"button\" class=\"btn btn-outline-info btn-sm\" title=\"View\" data-action=\"view\" data-path=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\"><i class=\"fas fa-download\"></i></button> <button type=\"button\" class=\"btn btn-outline-info btn-sm\" title=\"View\" data-action=\"view\" data-path=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath) + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 239, Col: 128} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"><i class=\"fas fa-eye\"></i></button> ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><i class=\"fas fa-eye\"></i></button> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Properties\" data-action=\"properties\" data-path=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Properties\" data-action=\"properties\" data-path=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath) + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 243, Col: 144} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><i class=\"fas fa-info-circle\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger btn-sm\" title=\"Delete\" data-action=\"delete\" data-path=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"><i class=\"fas fa-info-circle\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger btn-sm\" title=\"Delete\" data-action=\"delete\" data-path=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var25 string - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath) + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 246, Col: 133} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"><i class=\"fas fa-trash\"></i></button></div></td></tr>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"><i class=\"fas fa-trash\"></i></button></div></td></tr>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</tbody></table></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</tbody></table></div>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"text-center py-5\"><i class=\"fas fa-folder-open fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">Empty Directory</h5><p class=\"text-muted\">This directory contains no files or subdirectories.</p></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"text-center py-5\"><i class=\"fas fa-folder-open fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">Empty Directory</h5><p class=\"text-muted\">This directory contains no files or subdirectories.</p></div>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var26 string - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, 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/file_browser.templ`, Line: 271, Col: 66} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</small></div></div><!-- Create Folder Modal --><div class=\"modal fade\" id=\"createFolderModal\" tabindex=\"-1\" aria-labelledby=\"createFolderModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createFolderModalLabel\"><i class=\"fas fa-folder-plus me-2\"></i>Create New Folder</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"createFolderForm\"><div class=\"mb-3\"><label for=\"folderName\" class=\"form-label\">Folder Name</label> <input type=\"text\" class=\"form-control\" id=\"folderName\" name=\"folderName\" required placeholder=\"Enter folder name\" maxlength=\"255\"><div class=\"form-text\">Folder names cannot contain / or \\ characters.</div></div><input type=\"hidden\" id=\"currentPath\" name=\"currentPath\" value=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</small></div></div><!-- Create Folder Modal --><div class=\"modal fade\" id=\"createFolderModal\" tabindex=\"-1\" aria-labelledby=\"createFolderModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createFolderModalLabel\"><i class=\"fas fa-folder-plus me-2\"></i>Create New Folder</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"createFolderForm\"><div class=\"mb-3\"><label for=\"folderName\" class=\"form-label\">Folder Name</label> <input type=\"text\" class=\"form-control\" id=\"folderName\" name=\"folderName\" required placeholder=\"Enter folder name\" maxlength=\"255\"><div class=\"form-text\">Folder names cannot contain / or \\ characters.</div></div><input type=\"hidden\" id=\"currentPath\" name=\"currentPath\" value=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath) + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 296, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\"></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitCreateFolder()\"><i class=\"fas fa-folder-plus me-1\"></i>Create Folder</button></div></div></div></div><!-- Upload File Modal --><div class=\"modal fade\" id=\"uploadFileModal\" tabindex=\"-1\" aria-labelledby=\"uploadFileModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"uploadFileModalLabel\"><i class=\"fas fa-upload me-2\"></i>Upload Files</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"uploadFileForm\" enctype=\"multipart/form-data\"><div class=\"mb-3\"><label for=\"fileInput\" class=\"form-label\">Select Files</label> <input type=\"file\" class=\"form-control\" id=\"fileInput\" name=\"files\" multiple required><div class=\"form-text\">Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.</div></div><input type=\"hidden\" id=\"uploadPath\" name=\"path\" value=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitCreateFolder()\"><i class=\"fas fa-folder-plus me-1\"></i>Create Folder</button></div></div></div></div><!-- Upload File Modal --><div class=\"modal fade\" id=\"uploadFileModal\" tabindex=\"-1\" aria-labelledby=\"uploadFileModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"uploadFileModalLabel\"><i class=\"fas fa-upload me-2\"></i>Upload Files</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"uploadFileForm\" enctype=\"multipart/form-data\"><div class=\"mb-3\"><label for=\"fileInput\" class=\"form-label\">Select Files</label> <input type=\"file\" class=\"form-control\" id=\"fileInput\" name=\"files\" multiple required><div class=\"form-text\">Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.</div></div><input type=\"hidden\" id=\"uploadPath\" name=\"path\" value=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var28 string - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath) + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 328, Col: 79} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\"><!-- File List Preview --><div id=\"fileListPreview\" class=\"mb-3\" style=\"display: none;\"><label class=\"form-label\">Selected Files:</label><div id=\"selectedFilesList\" class=\"border rounded p-2 bg-light\"><!-- Files will be listed here --></div></div><!-- Upload Progress --><div class=\"mb-3\" id=\"uploadProgress\" style=\"display: none;\"><label class=\"form-label\">Upload Progress:</label><div class=\"progress mb-2\"><div class=\"progress-bar progress-bar-striped progress-bar-animated\" role=\"progressbar\" style=\"width: 0%\" aria-valuenow=\"0\" aria-valuemin=\"0\" aria-valuemax=\"100\">0%</div></div><div id=\"uploadStatus\" class=\"small text-muted\">Preparing upload...</div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitUploadFile()\"><i class=\"fas fa-upload me-1\"></i>Upload Files</button></div></div></div></div>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"><!-- File List Preview --><div id=\"fileListPreview\" class=\"mb-3\" style=\"display: none;\"><label class=\"form-label\">Selected Files:</label><div id=\"selectedFilesList\" class=\"border rounded p-2 bg-light\"><!-- Files will be listed here --></div></div><!-- Upload Progress --><div class=\"mb-3\" id=\"uploadProgress\" style=\"display: none;\"><label class=\"form-label\">Upload Progress:</label><div class=\"progress mb-2\"><div class=\"progress-bar progress-bar-striped progress-bar-animated\" role=\"progressbar\" style=\"width: 0%\" aria-valuenow=\"0\" aria-valuemin=\"0\" aria-valuemax=\"100\">0%</div></div><div id=\"uploadStatus\" class=\"small text-muted\">Preparing upload...</div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitUploadFile()\"><i class=\"fas fa-upload me-1\"></i>Upload Files</button></div></div></div></div><!-- JavaScript for file browser functionality --><script>\n\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t// Format permissions in the main table\n\t\tdocument.querySelectorAll('.permissions-display').forEach(element => {\n\t\t\tconst mode = element.getAttribute('data-mode');\n\t\t\tconst isDirectory = element.getAttribute('data-is-directory') === 'true';\n\t\t\tif (mode) {\n\t\t\t\telement.textContent = formatPermissions(mode, isDirectory);\n\t\t\t}\n\t\t});\n\t\t\n\t\t// Handle file browser action buttons (download, view, properties, delete)\n\t\tdocument.addEventListener('click', function(e) {\n\t\t\tconst button = e.target.closest('[data-action]');\n\t\t\tif (!button) return;\n\t\t\t\n\t\t\tconst action = button.getAttribute('data-action');\n\t\t\tconst path = button.getAttribute('data-path');\n\t\t\t\n\t\t\tif (!path) return;\n\t\t\t\n\t\t\tswitch(action) {\n\t\t\t\tcase 'download':\n\t\t\t\t\tdownloadFile(path);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'view':\n\t\t\t\t\tviewFile(path);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'properties':\n\t\t\t\t\tshowFileProperties(path);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'delete':\n\t\t\t\t\tif (confirm('Are you sure you want to delete \"' + path + '\"?')) {\n\t\t\t\t\t\tdeleteFile(path);\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t});\n\t\t\n\t\t// Initialize file manager event handlers from admin.js\n\t\tif (typeof setupFileManagerEventHandlers === 'function') {\n\t\t\tsetupFileManagerEventHandlers();\n\t\t}\n\t});\n\t\n\t// File browser specific functions\n\tfunction downloadFile(path) {\n\t\t// Open download URL in new tab\n\t\twindow.open('/api/files/download?path=' + encodeURIComponent(path), '_blank');\n\t}\n\t\n\tfunction viewFile(path) {\n\t\t// Open file viewer in new tab\n\t\twindow.open('/api/files/view?path=' + encodeURIComponent(path), '_blank');\n\t}\n\t\n\tfunction showFileProperties(path) {\n\t\t// Fetch file properties and show in modal\n\t\tfetch('/api/files/properties?path=' + encodeURIComponent(path))\n\t\t\t.then(response => response.json())\n\t\t\t.then(data => {\n\t\t\t\tif (data.error) {\n\t\t\t\t\talert('Error loading file properties: ' + data.error);\n\t\t\t\t} else {\n\t\t\t\t\tdisplayFileProperties(data);\n\t\t\t\t}\n\t\t\t})\n\t\t\t.catch(error => {\n\t\t\t\tconsole.error('Error fetching file properties:', error);\n\t\t\t\talert('Error loading file properties: ' + error.message);\n\t\t\t});\n\t}\n\t\n\tfunction displayFileProperties(data) {\n\t\t// Create a comprehensive modal for file properties\n\t\tconst modalHtml = '<div class=\"modal fade\" id=\"filePropertiesModal\" tabindex=\"-1\">' +\n\t\t\t'<div class=\"modal-dialog modal-lg\">' +\n\t\t\t'<div class=\"modal-content\">' +\n\t\t\t'<div class=\"modal-header\">' +\n\t\t\t'<h5 class=\"modal-title\"><i class=\"fas fa-info-circle me-2\"></i>Properties: ' + (data.name || 'Unknown') + '</h5>' +\n\t\t\t'<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-body\">' +\n\t\t\tcreateFilePropertiesContent(data) +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-footer\">' +\n\t\t\t'<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Remove existing modal if present\n\t\tconst existingModal = document.getElementById('filePropertiesModal');\n\t\tif (existingModal) {\n\t\t\texistingModal.remove();\n\t\t}\n\t\t\n\t\t// Add modal to body and show\n\t\tdocument.body.insertAdjacentHTML('beforeend', modalHtml);\n\t\tconst modal = new bootstrap.Modal(document.getElementById('filePropertiesModal'));\n\t\tmodal.show();\n\t\t\n\t\t// Remove modal when hidden\n\t\tdocument.getElementById('filePropertiesModal').addEventListener('hidden.bs.modal', function() {\n\t\t\tthis.remove();\n\t\t});\n\t}\n\t\n\tfunction createFilePropertiesContent(data) {\n\t\tlet html = '<div class=\"row\">' +\n\t\t\t'<div class=\"col-12\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-file me-1\"></i>Basic Information</h6>' +\n\t\t\t'<table class=\"table table-sm\">' +\n\t\t\t'<tr><td style=\"width: 120px;\"><strong>Name:</strong></td><td>' + (data.name || 'N/A') + '</td></tr>' +\n\t\t\t'<tr><td><strong>Full Path:</strong></td><td><code class=\"text-break\">' + (data.full_path || 'N/A') + '</code></td></tr>' +\n\t\t\t'<tr><td><strong>Type:</strong></td><td>' + (data.is_directory ? 'Directory' : 'File') + '</td></tr>';\n\t\t\n\t\tif (!data.is_directory) {\n\t\t\thtml += '<tr><td><strong>Size:</strong></td><td>' + (data.size_formatted || (data.size ? formatBytes(data.size) : 'N/A')) + '</td></tr>' +\n\t\t\t\t'<tr><td><strong>MIME Type:</strong></td><td>' + (data.mime_type || 'N/A') + '</td></tr>';\n\t\t}\n\t\t\n\t\thtml += '</table>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"row\">' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-clock me-1\"></i>Timestamps</h6>' +\n\t\t\t'<table class=\"table table-sm\">';\n\t\t\n\t\tif (data.modified_time) {\n\t\t\thtml += '<tr><td><strong>Modified:</strong></td><td>' + data.modified_time + '</td></tr>';\n\t\t}\n\t\tif (data.created_time) {\n\t\t\thtml += '<tr><td><strong>Created:</strong></td><td>' + data.created_time + '</td></tr>';\n\t\t}\n\t\t\n\t\thtml += '</table>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-shield-alt me-1\"></i>Permissions</h6>' +\n\t\t\t'<table class=\"table table-sm\">';\n\t\t\n\t\tif (data.file_mode) {\n\t\t\tconst rwxPermissions = formatPermissions(data.file_mode, data.is_directory);\n\t\t\thtml += '<tr><td><strong>Permissions:</strong></td><td><code>' + rwxPermissions + '</code></td></tr>';\n\t\t}\n\t\tif (data.uid !== undefined) {\n\t\t\thtml += '<tr><td><strong>User ID:</strong></td><td>' + data.uid + '</td></tr>';\n\t\t}\n\t\tif (data.gid !== undefined) {\n\t\t\thtml += '<tr><td><strong>Group ID:</strong></td><td>' + data.gid + '</td></tr>';\n\t\t}\n\t\t\n\t\thtml += '</table>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Add advanced info\n\t\thtml += '<div class=\"row\">' +\n\t\t\t'<div class=\"col-12\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-cog me-1\"></i>Advanced</h6>' +\n\t\t\t'<table class=\"table table-sm\">';\n\t\t\n\t\tif (data.chunk_count) {\n\t\t\thtml += '<tr><td style=\"width: 120px;\"><strong>Chunks:</strong></td><td>' + data.chunk_count + '</td></tr>';\n\t\t}\n\t\tif (data.ttl_formatted) {\n\t\t\thtml += '<tr><td><strong>TTL:</strong></td><td>' + data.ttl_formatted + '</td></tr>';\n\t\t}\n\t\t\n\t\thtml += '</table>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Add chunk details if available (show top 5)\n\t\tif (data.chunks && data.chunks.length > 0) {\n\t\t\tconst chunksToShow = data.chunks.slice(0, 5);\n\t\t\thtml += '<div class=\"row mt-3\">' +\n\t\t\t\t'<div class=\"col-12\">' +\n\t\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-puzzle-piece me-1\"></i>Chunk Details' +\n\t\t\t\t(data.chunk_count > 5 ? ' (Top 5 of ' + data.chunk_count + ')' : ' (' + data.chunk_count + ')') +\n\t\t\t\t'</h6>' +\n\t\t\t\t'<div class=\"table-responsive\" style=\"max-height: 200px; overflow-y: auto;\">' +\n\t\t\t\t'<table class=\"table table-sm table-striped\">' +\n\t\t\t\t'<thead>' +\n\t\t\t\t'<tr>' +\n\t\t\t\t'<th>File ID</th>' +\n\t\t\t\t'<th>Offset</th>' +\n\t\t\t\t'<th>Size</th>' +\n\t\t\t\t'<th>ETag</th>' +\n\t\t\t\t'</tr>' +\n\t\t\t\t'</thead>' +\n\t\t\t\t'<tbody>';\n\t\t\t\n\t\t\tchunksToShow.forEach(chunk => {\n\t\t\t\thtml += '<tr>' +\n\t\t\t\t\t'<td><code class=\"small\">' + (chunk.file_id || 'N/A') + '</code></td>' +\n\t\t\t\t\t'<td>' + formatBytes(chunk.offset || 0) + '</td>' +\n\t\t\t\t\t'<td>' + formatBytes(chunk.size || 0) + '</td>' +\n\t\t\t\t\t'<td><code class=\"small\">' + (chunk.e_tag || 'N/A') + '</code></td>' +\n\t\t\t\t\t'</tr>';\n\t\t\t});\n\t\t\t\n\t\t\thtml += '</tbody>' +\n\t\t\t\t'</table>' +\n\t\t\t\t'</div>' +\n\t\t\t\t'</div>' +\n\t\t\t\t'</div>';\n\t\t}\n\t\t\n\t\t// Add extended attributes if present\n\t\tif (data.extended && Object.keys(data.extended).length > 0) {\n\t\t\thtml += '<div class=\"row\">' +\n\t\t\t\t'<div class=\"col-12\">' +\n\t\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-tags me-1\"></i>Extended Attributes</h6>' +\n\t\t\t\t'<table class=\"table table-sm\">';\n\t\t\t\n\t\t\tfor (const [key, value] of Object.entries(data.extended)) {\n\t\t\t\thtml += '<tr><td><strong>' + key + ':</strong></td><td>' + value + '</td></tr>';\n\t\t\t}\n\t\t\t\n\t\t\thtml += '</table>' +\n\t\t\t\t'</div>' +\n\t\t\t\t'</div>';\n\t\t}\n\t\t\n\t\treturn html;\n\t}\n\t\n\tfunction uploadFile() {\n\t\tconst modal = new bootstrap.Modal(document.getElementById('uploadFileModal'));\n\t\tmodal.show();\n\t}\n\t\n\tfunction toggleSelectAll() {\n\t\tconst selectAllCheckbox = document.getElementById('selectAll');\n\t\tconst checkboxes = document.querySelectorAll('.file-checkbox');\n\t\t\n\t\tcheckboxes.forEach(checkbox => {\n\t\t\tcheckbox.checked = selectAllCheckbox.checked;\n\t\t});\n\t\t\n\t\tupdateDeleteSelectedButton();\n\t}\n\t\n\tfunction updateDeleteSelectedButton() {\n\t\tconst checkboxes = document.querySelectorAll('.file-checkbox:checked');\n\t\tconst deleteBtn = document.getElementById('deleteSelectedBtn');\n\t\t\n\t\tif (checkboxes.length > 0) {\n\t\t\tdeleteBtn.style.display = 'inline-block';\n\t\t} else {\n\t\t\tdeleteBtn.style.display = 'none';\n\t\t}\n\t}\n\t\n\t// Helper function to format bytes\n\tfunction formatBytes(bytes) {\n\t\tif (bytes === 0) return '0 Bytes';\n\t\tconst k = 1024;\n\t\tconst sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n\t\tconst i = Math.floor(Math.log(bytes) / Math.log(k));\n\t\treturn parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n\t}\n\t\n\t// Helper function to format permissions in rwxrwxrwx format\n\tfunction formatPermissions(mode, isDirectory) {\n\t\t// Check if mode is already in rwxrwxrwx format (e.g., \"drwxr-xr-x\" or \"-rw-r--r--\")\n\t\tif (mode && (mode.startsWith('d') || mode.startsWith('-') || mode.startsWith('l')) && mode.length === 10) {\n\t\t\treturn mode; // Already formatted\n\t\t}\n\t\t\n\t\t// Convert to number - could be octal string or decimal\n\t\tlet permissions;\n\t\tif (typeof mode === 'string') {\n\t\t\t// Try parsing as octal first, then decimal\n\t\t\tif (mode.startsWith('0') && mode.length <= 4) {\n\t\t\t\tpermissions = parseInt(mode, 8);\n\t\t\t} else {\n\t\t\t\tpermissions = parseInt(mode, 10);\n\t\t\t}\n\t\t} else {\n\t\t\tpermissions = parseInt(mode, 10);\n\t\t}\n\t\t\n\t\tif (isNaN(permissions)) {\n\t\t\treturn isDirectory ? 'drwxr-xr-x' : '-rw-r--r--'; // Default fallback\n\t\t}\n\t\t\n\t\t// Handle Go's os.ModeDir conversion\n\t\t// Go's os.ModeDir is 0x80000000 (2147483648), but Unix S_IFDIR is 0o40000 (16384)\n\t\tlet fileType = '-';\n\t\t\n\t\t// Check for Go's os.ModeDir flag\n\t\tif (permissions & 0x80000000) {\n\t\t\tfileType = 'd';\n\t\t}\n\t\t// Check for standard Unix file type bits\n\t\telse if ((permissions & 0xF000) === 0x4000) { // S_IFDIR (0o40000)\n\t\t\tfileType = 'd';\n\t\t} else if ((permissions & 0xF000) === 0x8000) { // S_IFREG (0o100000)\n\t\t\tfileType = '-';\n\t\t} else if ((permissions & 0xF000) === 0xA000) { // S_IFLNK (0o120000)\n\t\t\tfileType = 'l';\n\t\t} else if ((permissions & 0xF000) === 0x2000) { // S_IFCHR (0o020000)\n\t\t\tfileType = 'c';\n\t\t} else if ((permissions & 0xF000) === 0x6000) { // S_IFBLK (0o060000)\n\t\t\tfileType = 'b';\n\t\t} else if ((permissions & 0xF000) === 0x1000) { // S_IFIFO (0o010000)\n\t\t\tfileType = 'p';\n\t\t} else if ((permissions & 0xF000) === 0xC000) { // S_IFSOCK (0o140000)\n\t\t\tfileType = 's';\n\t\t}\n\t\t// Fallback to isDirectory parameter if file type detection fails\n\t\telse if (isDirectory) {\n\t\t\tfileType = 'd';\n\t\t}\n\t\t\n\t\t// Permission bits (always use the lower 12 bits for permissions)\n\t\tconst owner = (permissions >> 6) & 7;\n\t\tconst group = (permissions >> 3) & 7;\n\t\tconst others = permissions & 7;\n\t\t\n\t\t// Convert number to rwx format\n\t\tfunction numToRwx(num) {\n\t\t\tconst r = (num & 4) ? 'r' : '-';\n\t\t\tconst w = (num & 2) ? 'w' : '-';\n\t\t\tconst x = (num & 1) ? 'x' : '-';\n\t\t\treturn r + w + x;\n\t\t}\n\t\t\n\t\treturn fileType + numToRwx(owner) + numToRwx(group) + numToRwx(others);\n\t}\n\t\n\tfunction exportFileList() {\n\t\t// Simple CSV export of file list\n\t\tconst rows = Array.from(document.querySelectorAll('#fileTable tbody tr')).map(row => {\n\t\t\tconst cells = row.querySelectorAll('td');\n\t\t\tif (cells.length > 1) {\n\t\t\t\treturn {\n\t\t\t\t\tname: cells[1].textContent.trim(),\n\t\t\t\t\tsize: cells[2].textContent.trim(),\n\t\t\t\t\ttype: cells[3].textContent.trim(),\n\t\t\t\t\tmodified: cells[4].textContent.trim(),\n\t\t\t\t\tpermissions: cells[5].textContent.trim()\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t}).filter(row => row !== null);\n\t\t\n\t\tconst csvContent = \"data:text/csv;charset=utf-8,\" + \n\t\t\t\"Name,Size,Type,Modified,Permissions\\n\" +\n\t\t\trows.map(r => '\"' + r.name + '\",\"' + r.size + '\",\"' + r.type + '\",\"' + r.modified + '\",\"' + r.permissions + '\"').join(\"\\n\");\n\t\t\n\t\tconst encodedUri = encodeURI(csvContent);\n\t\tconst link = document.createElement(\"a\");\n\t\tlink.setAttribute(\"href\", encodedUri);\n\t\tlink.setAttribute(\"download\", \"files.csv\");\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n\t\n\t// Handle file checkbox changes\n\tdocument.addEventListener('change', function(e) {\n\t\tif (e.target.classList.contains('file-checkbox')) {\n\t\t\tupdateDeleteSelectedButton();\n\t\t}\n\t});\n\t</script>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/object_store_users.templ b/weed/admin/view/app/object_store_users.templ index 7c457a3d8..dedd258e2 100644 --- a/weed/admin/view/app/object_store_users.templ +++ b/weed/admin/view/app/object_store_users.templ @@ -317,7 +317,355 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) { <!-- JavaScript for user management --> <script> - // User management functions will be included in admin.js + document.addEventListener('DOMContentLoaded', function() { + // Event delegation for user action buttons + document.addEventListener('click', function(e) { + const button = e.target.closest('[data-action]'); + if (!button) return; + + const action = button.getAttribute('data-action'); + const username = button.getAttribute('data-username'); + + switch (action) { + case 'show-user-details': + showUserDetails(username); + break; + case 'edit-user': + editUser(username); + break; + case 'manage-access-keys': + manageAccessKeys(username); + break; + case 'delete-user': + deleteUser(username); + break; + } + }); + }); + + // Show user details modal + async function showUserDetails(username) { + try { + const response = await fetch(`/api/users/${username}`); + if (response.ok) { + const user = await response.json(); + document.getElementById('userDetailsContent').innerHTML = createUserDetailsContent(user); + const modal = new bootstrap.Modal(document.getElementById('userDetailsModal')); + modal.show(); + } else { + showErrorMessage('Failed to load user details'); + } + } catch (error) { + console.error('Error loading user details:', error); + showErrorMessage('Failed to load user details'); + } + } + + // Edit user function + async function editUser(username) { + try { + const response = await fetch(`/api/users/${username}`); + if (response.ok) { + const user = await response.json(); + + // Populate edit form + document.getElementById('editUsername').value = username; + document.getElementById('editEmail').value = user.email || ''; + + // Set selected actions + const actionsSelect = document.getElementById('editActions'); + Array.from(actionsSelect.options).forEach(option => { + option.selected = user.actions && user.actions.includes(option.value); + }); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('editUserModal')); + modal.show(); + } else { + showErrorMessage('Failed to load user details'); + } + } catch (error) { + console.error('Error loading user:', error); + showErrorMessage('Failed to load user details'); + } + } + + // Manage access keys function + async function manageAccessKeys(username) { + try { + const response = await fetch(`/api/users/${username}`); + if (response.ok) { + const user = await response.json(); + document.getElementById('accessKeysUsername').textContent = username; + document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user); + const modal = new bootstrap.Modal(document.getElementById('accessKeysModal')); + modal.show(); + } else { + showErrorMessage('Failed to load access keys'); + } + } catch (error) { + console.error('Error loading access keys:', error); + showErrorMessage('Failed to load access keys'); + } + } + + // Delete user function + async function deleteUser(username) { + if (confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) { + try { + const response = await fetch(`/api/users/${username}`, { + method: 'DELETE' + }); + + if (response.ok) { + showSuccessMessage('User deleted successfully'); + setTimeout(() => window.location.reload(), 1000); + } else { + const error = await response.json(); + showErrorMessage('Failed to delete user: ' + (error.error || 'Unknown error')); + } + } catch (error) { + console.error('Error deleting user:', error); + showErrorMessage('Failed to delete user: ' + error.message); + } + } + } + + // Handle create user form submission + async function handleCreateUser() { + const form = document.getElementById('createUserForm'); + const formData = new FormData(form); + + const userData = { + username: formData.get('username'), + email: formData.get('email'), + actions: Array.from(document.getElementById('actions').selectedOptions).map(option => option.value), + generate_key: document.getElementById('generateKey').checked + }; + + try { + const response = await fetch('/api/users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userData) + }); + + if (response.ok) { + const result = await response.json(); + showSuccessMessage('User created successfully'); + + // Show the created access key if generated + if (result.user && result.user.access_key) { + showNewAccessKeyModal(result.user); + } + + // Close modal and refresh page + const modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal')); + modal.hide(); + form.reset(); + setTimeout(() => window.location.reload(), 1000); + } else { + const error = await response.json(); + showErrorMessage('Failed to create user: ' + (error.error || 'Unknown error')); + } + } catch (error) { + console.error('Error creating user:', error); + showErrorMessage('Failed to create user: ' + error.message); + } + } + + // Handle update user form submission + async function handleUpdateUser() { + const username = document.getElementById('editUsername').value; + const formData = new FormData(document.getElementById('editUserForm')); + + const userData = { + email: formData.get('email'), + actions: Array.from(document.getElementById('editActions').selectedOptions).map(option => option.value) + }; + + try { + const response = await fetch(`/api/users/${username}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userData) + }); + + if (response.ok) { + showSuccessMessage('User updated successfully'); + + // Close modal and refresh page + const modal = bootstrap.Modal.getInstance(document.getElementById('editUserModal')); + modal.hide(); + setTimeout(() => window.location.reload(), 1000); + } else { + const error = await response.json(); + showErrorMessage('Failed to update user: ' + (error.error || 'Unknown error')); + } + } catch (error) { + console.error('Error updating user:', error); + showErrorMessage('Failed to update user: ' + error.message); + } + } + + // Create user details content + function createUserDetailsContent(user) { + var detailsHtml = '<div class="row">'; + detailsHtml += '<div class="col-md-6">'; + detailsHtml += '<h6 class="text-muted">Basic Information</h6>'; + detailsHtml += '<table class="table table-sm">'; + detailsHtml += '<tr><td><strong>Username:</strong></td><td>' + escapeHtml(user.username) + '</td></tr>'; + detailsHtml += '<tr><td><strong>Email:</strong></td><td>' + escapeHtml(user.email || 'Not set') + '</td></tr>'; + detailsHtml += '</table>'; + detailsHtml += '</div>'; + detailsHtml += '<div class="col-md-6">'; + detailsHtml += '<h6 class="text-muted">Permissions</h6>'; + detailsHtml += '<div class="mb-3">'; + if (user.actions && user.actions.length > 0) { + detailsHtml += user.actions.map(function(action) { + return '<span class="badge bg-info me-1">' + action + '</span>'; + }).join(''); + } else { + detailsHtml += '<span class="text-muted">No permissions assigned</span>'; + } + detailsHtml += '</div>'; + detailsHtml += '<h6 class="text-muted">Access Keys</h6>'; + if (user.access_keys && user.access_keys.length > 0) { + detailsHtml += '<div class="mb-2">'; + user.access_keys.forEach(function(key) { + detailsHtml += '<div><code class="text-muted">' + key.access_key + '</code></div>'; + }); + detailsHtml += '</div>'; + } else { + detailsHtml += '<p class="text-muted">No access keys</p>'; + } + detailsHtml += '</div>'; + detailsHtml += '</div>'; + return detailsHtml; + } + + // Create access keys content + function createAccessKeysContent(user) { + if (!user.access_keys || user.access_keys.length === 0) { + return '<p class="text-muted">No access keys available</p>'; + } + + var keysHtml = '<div class="table-responsive">'; + keysHtml += '<table class="table table-sm">'; + keysHtml += '<thead><tr><th>Access Key</th><th>Status</th><th>Actions</th></tr></thead>'; + keysHtml += '<tbody>'; + + user.access_keys.forEach(function(key) { + keysHtml += '<tr>'; + keysHtml += '<td><code>' + key.access_key + '</code></td>'; + keysHtml += '<td><span class="badge bg-success">Active</span></td>'; + keysHtml += '<td>'; + keysHtml += '<button class="btn btn-outline-danger btn-sm" onclick="deleteAccessKey(\'' + user.username + '\', \'' + key.access_key + '\')">'; + keysHtml += '<i class="fas fa-trash"></i> Delete'; + keysHtml += '</button>'; + keysHtml += '</td>'; + keysHtml += '</tr>'; + }); + + keysHtml += '</tbody>'; + keysHtml += '</table>'; + keysHtml += '</div>'; + return keysHtml; + } + + // Create new access key + async function createAccessKey() { + const username = document.getElementById('accessKeysUsername').textContent; + + try { + const response = await fetch(`/api/users/${username}/access-keys`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}) + }); + + if (response.ok) { + const result = await response.json(); + showSuccessMessage('Access key created successfully'); + + // Refresh access keys display + const userResponse = await fetch(`/api/users/${username}`); + if (userResponse.ok) { + const user = await userResponse.json(); + document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user); + } + } else { + const error = await response.json(); + showErrorMessage('Failed to create access key: ' + (error.error || 'Unknown error')); + } + } catch (error) { + console.error('Error creating access key:', error); + showErrorMessage('Failed to create access key: ' + error.message); + } + } + + // Delete access key + async function deleteAccessKey(username, accessKey) { + if (confirm('Are you sure you want to delete this access key?')) { + try { + const response = await fetch(`/api/users/${username}/access-keys/${accessKey}`, { + method: 'DELETE' + }); + + if (response.ok) { + showSuccessMessage('Access key deleted successfully'); + + // Refresh access keys display + const userResponse = await fetch(`/api/users/${username}`); + if (userResponse.ok) { + const user = await userResponse.json(); + document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user); + } + } else { + const error = await response.json(); + showErrorMessage('Failed to delete access key: ' + (error.error || 'Unknown error')); + } + } catch (error) { + console.error('Error deleting access key:', error); + showErrorMessage('Failed to delete access key: ' + error.message); + } + } + } + + // Show new access key modal (when user is created with generated key) + function showNewAccessKeyModal(user) { + // Create a simple alert for now - could be enhanced with a dedicated modal + var message = 'New user created!\n\n'; + message += 'Username: ' + user.username + '\n'; + message += 'Access Key: ' + user.access_key + '\n'; + message += 'Secret Key: ' + user.secret_key + '\n\n'; + message += 'Please save these credentials securely.'; + alert(message); + } + + // Utility functions + function showSuccessMessage(message) { + // Simple implementation - could be enhanced with toast notifications + alert('Success: ' + message); + } + + function showErrorMessage(message) { + // Simple implementation - could be enhanced with toast notifications + alert('Error: ' + message); + } + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } </script> } diff --git a/weed/admin/view/app/object_store_users_templ.go b/weed/admin/view/app/object_store_users_templ.go index a4a194d59..8d08d5161 100644 --- a/weed/admin/view/app/object_store_users_templ.go +++ b/weed/admin/view/app/object_store_users_templ.go @@ -193,7 +193,7 @@ func ObjectStoreUsers(data dash.ObjectStoreUsersData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</small></div></div></div><!-- Create User Modal --><div class=\"modal fade\" id=\"createUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-plus me-2\"></i>Create New User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"createUserForm\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username *</label> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div><div class=\"mb-3\"><label for=\"email\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"email\" name=\"email\"></div><div class=\"mb-3\"><label for=\"actions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"actions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple permissions</small></div><div class=\"mb-3 form-check\"><input type=\"checkbox\" class=\"form-check-input\" id=\"generateKey\" name=\"generateKey\" checked> <label class=\"form-check-label\" for=\"generateKey\">Generate access key automatically</label></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleCreateUser()\">Create User</button></div></div></div></div><!-- Edit User Modal --><div class=\"modal fade\" id=\"editUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-edit me-2\"></i>Edit User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"editUserForm\"><input type=\"hidden\" id=\"editUsername\" name=\"username\"><div class=\"mb-3\"><label for=\"editEmail\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"editEmail\" name=\"email\"></div><div class=\"mb-3\"><label for=\"editActions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"editActions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleUpdateUser()\">Update User</button></div></div></div></div><!-- User Details Modal --><div class=\"modal fade\" id=\"userDetailsModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user me-2\"></i>User Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\" id=\"userDetailsContent\"><!-- Content will be loaded dynamically --></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- Access Keys Management Modal --><div class=\"modal fade\" id=\"accessKeysModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-key me-2\"></i>Manage Access Keys</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><div class=\"d-flex justify-content-between align-items-center mb-3\"><h6>Access Keys for <span id=\"accessKeysUsername\"></span></h6><button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createAccessKey()\"><i class=\"fas fa-plus me-1\"></i>Create New Key</button></div><div id=\"accessKeysContent\"><!-- Content will be loaded dynamically --></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- JavaScript for user management --><script>\n // User management functions will be included in admin.js\n </script>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</small></div></div></div><!-- Create User Modal --><div class=\"modal fade\" id=\"createUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-plus me-2\"></i>Create New User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"createUserForm\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username *</label> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div><div class=\"mb-3\"><label for=\"email\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"email\" name=\"email\"></div><div class=\"mb-3\"><label for=\"actions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"actions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple permissions</small></div><div class=\"mb-3 form-check\"><input type=\"checkbox\" class=\"form-check-input\" id=\"generateKey\" name=\"generateKey\" checked> <label class=\"form-check-label\" for=\"generateKey\">Generate access key automatically</label></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleCreateUser()\">Create User</button></div></div></div></div><!-- Edit User Modal --><div class=\"modal fade\" id=\"editUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-edit me-2\"></i>Edit User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"editUserForm\"><input type=\"hidden\" id=\"editUsername\" name=\"username\"><div class=\"mb-3\"><label for=\"editEmail\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"editEmail\" name=\"email\"></div><div class=\"mb-3\"><label for=\"editActions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"editActions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleUpdateUser()\">Update User</button></div></div></div></div><!-- User Details Modal --><div class=\"modal fade\" id=\"userDetailsModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user me-2\"></i>User Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\" id=\"userDetailsContent\"><!-- Content will be loaded dynamically --></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- Access Keys Management Modal --><div class=\"modal fade\" id=\"accessKeysModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-key me-2\"></i>Manage Access Keys</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><div class=\"d-flex justify-content-between align-items-center mb-3\"><h6>Access Keys for <span id=\"accessKeysUsername\"></span></h6><button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createAccessKey()\"><i class=\"fas fa-plus me-1\"></i>Create New Key</button></div><div id=\"accessKeysContent\"><!-- Content will be loaded dynamically --></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- JavaScript for user management --><script>\n document.addEventListener('DOMContentLoaded', function() {\n // Event delegation for user action buttons\n document.addEventListener('click', function(e) {\n const button = e.target.closest('[data-action]');\n if (!button) return;\n \n const action = button.getAttribute('data-action');\n const username = button.getAttribute('data-username');\n \n switch (action) {\n case 'show-user-details':\n showUserDetails(username);\n break;\n case 'edit-user':\n editUser(username);\n break;\n case 'manage-access-keys':\n manageAccessKeys(username);\n break;\n case 'delete-user':\n deleteUser(username);\n break;\n }\n });\n });\n\n // Show user details modal\n async function showUserDetails(username) {\n try {\n const response = await fetch(`/api/users/${username}`);\n if (response.ok) {\n const user = await response.json();\n document.getElementById('userDetailsContent').innerHTML = createUserDetailsContent(user);\n const modal = new bootstrap.Modal(document.getElementById('userDetailsModal'));\n modal.show();\n } else {\n showErrorMessage('Failed to load user details');\n }\n } catch (error) {\n console.error('Error loading user details:', error);\n showErrorMessage('Failed to load user details');\n }\n }\n\n // Edit user function\n async function editUser(username) {\n try {\n const response = await fetch(`/api/users/${username}`);\n if (response.ok) {\n const user = await response.json();\n \n // Populate edit form\n document.getElementById('editUsername').value = username;\n document.getElementById('editEmail').value = user.email || '';\n \n // Set selected actions\n const actionsSelect = document.getElementById('editActions');\n Array.from(actionsSelect.options).forEach(option => {\n option.selected = user.actions && user.actions.includes(option.value);\n });\n \n // Show modal\n const modal = new bootstrap.Modal(document.getElementById('editUserModal'));\n modal.show();\n } else {\n showErrorMessage('Failed to load user details');\n }\n } catch (error) {\n console.error('Error loading user:', error);\n showErrorMessage('Failed to load user details');\n }\n }\n\n // Manage access keys function\n async function manageAccessKeys(username) {\n try {\n const response = await fetch(`/api/users/${username}`);\n if (response.ok) {\n const user = await response.json();\n document.getElementById('accessKeysUsername').textContent = username;\n document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);\n const modal = new bootstrap.Modal(document.getElementById('accessKeysModal'));\n modal.show();\n } else {\n showErrorMessage('Failed to load access keys');\n }\n } catch (error) {\n console.error('Error loading access keys:', error);\n showErrorMessage('Failed to load access keys');\n }\n }\n\n // Delete user function\n async function deleteUser(username) {\n if (confirm(`Are you sure you want to delete user \"${username}\"? This action cannot be undone.`)) {\n try {\n const response = await fetch(`/api/users/${username}`, {\n method: 'DELETE'\n });\n \n if (response.ok) {\n showSuccessMessage('User deleted successfully');\n setTimeout(() => window.location.reload(), 1000);\n } else {\n const error = await response.json();\n showErrorMessage('Failed to delete user: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error deleting user:', error);\n showErrorMessage('Failed to delete user: ' + error.message);\n }\n }\n }\n\n // Handle create user form submission\n async function handleCreateUser() {\n const form = document.getElementById('createUserForm');\n const formData = new FormData(form);\n \n const userData = {\n username: formData.get('username'),\n email: formData.get('email'),\n actions: Array.from(document.getElementById('actions').selectedOptions).map(option => option.value),\n generate_key: document.getElementById('generateKey').checked\n };\n \n try {\n const response = await fetch('/api/users', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(userData)\n });\n \n if (response.ok) {\n const result = await response.json();\n showSuccessMessage('User created successfully');\n \n // Show the created access key if generated\n if (result.user && result.user.access_key) {\n showNewAccessKeyModal(result.user);\n }\n \n // Close modal and refresh page\n const modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal'));\n modal.hide();\n form.reset();\n setTimeout(() => window.location.reload(), 1000);\n } else {\n const error = await response.json();\n showErrorMessage('Failed to create user: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error creating user:', error);\n showErrorMessage('Failed to create user: ' + error.message);\n }\n }\n\n // Handle update user form submission\n async function handleUpdateUser() {\n const username = document.getElementById('editUsername').value;\n const formData = new FormData(document.getElementById('editUserForm'));\n \n const userData = {\n email: formData.get('email'),\n actions: Array.from(document.getElementById('editActions').selectedOptions).map(option => option.value)\n };\n \n try {\n const response = await fetch(`/api/users/${username}`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(userData)\n });\n \n if (response.ok) {\n showSuccessMessage('User updated successfully');\n \n // Close modal and refresh page\n const modal = bootstrap.Modal.getInstance(document.getElementById('editUserModal'));\n modal.hide();\n setTimeout(() => window.location.reload(), 1000);\n } else {\n const error = await response.json();\n showErrorMessage('Failed to update user: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error updating user:', error);\n showErrorMessage('Failed to update user: ' + error.message);\n }\n }\n\n // Create user details content\n function createUserDetailsContent(user) {\n var detailsHtml = '<div class=\"row\">';\n detailsHtml += '<div class=\"col-md-6\">';\n detailsHtml += '<h6 class=\"text-muted\">Basic Information</h6>';\n detailsHtml += '<table class=\"table table-sm\">';\n detailsHtml += '<tr><td><strong>Username:</strong></td><td>' + escapeHtml(user.username) + '</td></tr>';\n detailsHtml += '<tr><td><strong>Email:</strong></td><td>' + escapeHtml(user.email || 'Not set') + '</td></tr>';\n detailsHtml += '</table>';\n detailsHtml += '</div>';\n detailsHtml += '<div class=\"col-md-6\">';\n detailsHtml += '<h6 class=\"text-muted\">Permissions</h6>';\n detailsHtml += '<div class=\"mb-3\">';\n if (user.actions && user.actions.length > 0) {\n detailsHtml += user.actions.map(function(action) {\n return '<span class=\"badge bg-info me-1\">' + action + '</span>';\n }).join('');\n } else {\n detailsHtml += '<span class=\"text-muted\">No permissions assigned</span>';\n }\n detailsHtml += '</div>';\n detailsHtml += '<h6 class=\"text-muted\">Access Keys</h6>';\n if (user.access_keys && user.access_keys.length > 0) {\n detailsHtml += '<div class=\"mb-2\">';\n user.access_keys.forEach(function(key) {\n detailsHtml += '<div><code class=\"text-muted\">' + key.access_key + '</code></div>';\n });\n detailsHtml += '</div>';\n } else {\n detailsHtml += '<p class=\"text-muted\">No access keys</p>';\n }\n detailsHtml += '</div>';\n detailsHtml += '</div>';\n return detailsHtml;\n }\n\n // Create access keys content\n function createAccessKeysContent(user) {\n if (!user.access_keys || user.access_keys.length === 0) {\n return '<p class=\"text-muted\">No access keys available</p>';\n }\n \n var keysHtml = '<div class=\"table-responsive\">';\n keysHtml += '<table class=\"table table-sm\">';\n keysHtml += '<thead><tr><th>Access Key</th><th>Status</th><th>Actions</th></tr></thead>';\n keysHtml += '<tbody>';\n \n user.access_keys.forEach(function(key) {\n keysHtml += '<tr>';\n keysHtml += '<td><code>' + key.access_key + '</code></td>';\n keysHtml += '<td><span class=\"badge bg-success\">Active</span></td>';\n keysHtml += '<td>';\n keysHtml += '<button class=\"btn btn-outline-danger btn-sm\" onclick=\"deleteAccessKey(\\'' + user.username + '\\', \\'' + key.access_key + '\\')\">';\n keysHtml += '<i class=\"fas fa-trash\"></i> Delete';\n keysHtml += '</button>';\n keysHtml += '</td>';\n keysHtml += '</tr>';\n });\n \n keysHtml += '</tbody>';\n keysHtml += '</table>';\n keysHtml += '</div>';\n return keysHtml;\n }\n\n // Create new access key\n async function createAccessKey() {\n const username = document.getElementById('accessKeysUsername').textContent;\n \n try {\n const response = await fetch(`/api/users/${username}/access-keys`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({})\n });\n \n if (response.ok) {\n const result = await response.json();\n showSuccessMessage('Access key created successfully');\n \n // Refresh access keys display\n const userResponse = await fetch(`/api/users/${username}`);\n if (userResponse.ok) {\n const user = await userResponse.json();\n document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);\n }\n } else {\n const error = await response.json();\n showErrorMessage('Failed to create access key: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error creating access key:', error);\n showErrorMessage('Failed to create access key: ' + error.message);\n }\n }\n\n // Delete access key\n async function deleteAccessKey(username, accessKey) {\n if (confirm('Are you sure you want to delete this access key?')) {\n try {\n const response = await fetch(`/api/users/${username}/access-keys/${accessKey}`, {\n method: 'DELETE'\n });\n \n if (response.ok) {\n showSuccessMessage('Access key deleted successfully');\n \n // Refresh access keys display\n const userResponse = await fetch(`/api/users/${username}`);\n if (userResponse.ok) {\n const user = await userResponse.json();\n document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);\n }\n } else {\n const error = await response.json();\n showErrorMessage('Failed to delete access key: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error deleting access key:', error);\n showErrorMessage('Failed to delete access key: ' + error.message);\n }\n }\n }\n\n // Show new access key modal (when user is created with generated key)\n function showNewAccessKeyModal(user) {\n // Create a simple alert for now - could be enhanced with a dedicated modal\n var message = 'New user created!\\n\\n';\n message += 'Username: ' + user.username + '\\n';\n message += 'Access Key: ' + user.access_key + '\\n';\n message += 'Secret Key: ' + user.secret_key + '\\n\\n';\n message += 'Please save these credentials securely.';\n alert(message);\n }\n\n // Utility functions\n function showSuccessMessage(message) {\n // Simple implementation - could be enhanced with toast notifications\n alert('Success: ' + message);\n }\n\n function showErrorMessage(message) {\n // Simple implementation - could be enhanced with toast notifications\n alert('Error: ' + message);\n }\n\n function escapeHtml(text) {\n if (!text) return '';\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n </script>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/policies.templ b/weed/admin/view/app/policies.templ new file mode 100644 index 000000000..e613d535e --- /dev/null +++ b/weed/admin/view/app/policies.templ @@ -0,0 +1,658 @@ +package app + +import ( + "fmt" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" +) + +templ Policies(data dash.PoliciesData) { + <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> + <h1 class="h2"> + <i class="fas fa-shield-alt me-2"></i>IAM Policies + </h1> + <div class="btn-toolbar mb-2 mb-md-0"> + <div class="btn-group me-2"> + <button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createPolicyModal"> + <i class="fas fa-plus me-1"></i>Create Policy + </button> + </div> + </div> + </div> + + <div id="policies-content"> + <!-- Summary Cards --> + <div class="row mb-4"> + <div class="col-xl-4 col-md-6 mb-4"> + <div class="card border-left-primary shadow h-100 py-2"> + <div class="card-body"> + <div class="row no-gutters align-items-center"> + <div class="col mr-2"> + <div class="text-xs font-weight-bold text-primary text-uppercase mb-1"> + Total Policies + </div> + <div class="h5 mb-0 font-weight-bold text-gray-800"> + {fmt.Sprintf("%d", data.TotalPolicies)} + </div> + </div> + <div class="col-auto"> + <i class="fas fa-shield-alt fa-2x text-gray-300"></i> + </div> + </div> + </div> + </div> + </div> + + <div class="col-xl-4 col-md-6 mb-4"> + <div class="card border-left-success shadow h-100 py-2"> + <div class="card-body"> + <div class="row no-gutters align-items-center"> + <div class="col mr-2"> + <div class="text-xs font-weight-bold text-success text-uppercase mb-1"> + Active Policies + </div> + <div class="h5 mb-0 font-weight-bold text-gray-800"> + {fmt.Sprintf("%d", data.TotalPolicies)} + </div> + </div> + <div class="col-auto"> + <i class="fas fa-check-circle fa-2x text-gray-300"></i> + </div> + </div> + </div> + </div> + </div> + + <div class="col-xl-4 col-md-6 mb-4"> + <div class="card border-left-info shadow h-100 py-2"> + <div class="card-body"> + <div class="row no-gutters align-items-center"> + <div class="col mr-2"> + <div class="text-xs font-weight-bold text-info text-uppercase mb-1"> + Last Updated + </div> + <div class="h5 mb-0 font-weight-bold text-gray-800"> + {data.LastUpdated.Format("15:04")} + </div> + </div> + <div class="col-auto"> + <i class="fas fa-clock fa-2x text-gray-300"></i> + </div> + </div> + </div> + </div> + </div> + </div> + + <!-- Policies Table --> + <div class="row"> + <div class="col-12"> + <div class="card shadow mb-4"> + <div class="card-header py-3 d-flex flex-row align-items-center justify-content-between"> + <h6 class="m-0 font-weight-bold text-primary"> + <i class="fas fa-shield-alt me-2"></i>IAM Policies + </h6> + <div class="dropdown no-arrow"> + <a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> + <i class="fas fa-ellipsis-v fa-sm fa-fw text-gray-400"></i> + </a> + <div class="dropdown-menu dropdown-menu-right shadow animated--fade-in"> + <div class="dropdown-header">Actions:</div> + <a class="dropdown-item" href="#"> + <i class="fas fa-download me-2"></i>Export List + </a> + </div> + </div> + </div> + <div class="card-body"> + <div class="table-responsive"> + <table class="table table-hover" width="100%" cellspacing="0"> + <thead> + <tr> + <th>Policy Name</th> + <th>Version</th> + <th>Statements</th> + <th>Created</th> + <th>Updated</th> + <th>Actions</th> + </tr> + </thead> + <tbody> + for _, policy := range data.Policies { + <tr> + <td> + <strong>{policy.Name}</strong> + </td> + <td> + <span class="badge bg-info">{policy.Document.Version}</span> + </td> + <td> + <span class="badge bg-secondary">{fmt.Sprintf("%d statements", len(policy.Document.Statement))}</span> + </td> + <td> + <small class="text-muted">{policy.CreatedAt.Format("2006-01-02 15:04")}</small> + </td> + <td> + <small class="text-muted">{policy.UpdatedAt.Format("2006-01-02 15:04")}</small> + </td> + <td> + <div class="btn-group btn-group-sm" role="group"> + <button type="button" class="btn btn-outline-info view-policy-btn" title="View Policy" data-policy-name={policy.Name}> + <i class="fas fa-eye"></i> + </button> + <button type="button" class="btn btn-outline-primary edit-policy-btn" title="Edit Policy" data-policy-name={policy.Name}> + <i class="fas fa-edit"></i> + </button> + <button type="button" class="btn btn-outline-danger delete-policy-btn" title="Delete Policy" data-policy-name={policy.Name}> + <i class="fas fa-trash"></i> + </button> + </div> + </td> + </tr> + } + if len(data.Policies) == 0 { + <tr> + <td colspan="6" class="text-center text-muted py-4"> + <i class="fas fa-shield-alt fa-3x mb-3 text-muted"></i> + <div> + <h5>No IAM policies found</h5> + <p>Create your first policy to manage access permissions.</p> + <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createPolicyModal"> + <i class="fas fa-plus me-1"></i>Create Policy + </button> + </div> + </td> + </tr> + } + </tbody> + </table> + </div> + </div> + </div> + </div> + </div> + </div> + + <!-- Create Policy Modal --> + <div class="modal fade" id="createPolicyModal" tabindex="-1" aria-labelledby="createPolicyModalLabel" aria-hidden="true"> + <div class="modal-dialog modal-lg"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="createPolicyModalLabel"> + <i class="fas fa-shield-alt me-2"></i>Create IAM Policy + </h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <form id="createPolicyForm"> + <div class="mb-3"> + <label for="policyName" class="form-label">Policy Name</label> + <input type="text" class="form-control" id="policyName" name="name" required placeholder="e.g., S3ReadOnlyPolicy"> + <div class="form-text">Enter a unique name for this policy (alphanumeric and underscores only)</div> + </div> + + <div class="mb-3"> + <label for="policyDocument" class="form-label">Policy Document</label> + <textarea class="form-control" id="policyDocument" name="document" rows="15" required placeholder="Enter IAM policy JSON document..."></textarea> + <div class="form-text">Enter the policy document in AWS IAM JSON format</div> + </div> + + <div class="mb-3"> + <div class="row"> + <div class="col-md-6"> + <button type="button" class="btn btn-outline-info btn-sm" onclick="insertSamplePolicy()"> + <i class="fas fa-file-alt me-1"></i>Use Sample Policy + </button> + </div> + <div class="col-md-6 text-end"> + <button type="button" class="btn btn-outline-secondary btn-sm" onclick="validatePolicyDocument()"> + <i class="fas fa-check me-1"></i>Validate JSON + </button> + </div> + </div> + </div> + </form> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> + <button type="button" class="btn btn-primary" onclick="createPolicy()"> + <i class="fas fa-plus me-1"></i>Create Policy + </button> + </div> + </div> + </div> + </div> + + <!-- View Policy Modal --> + <div class="modal fade" id="viewPolicyModal" tabindex="-1" aria-labelledby="viewPolicyModalLabel" aria-hidden="true"> + <div class="modal-dialog modal-lg"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="viewPolicyModalLabel"> + <i class="fas fa-eye me-2"></i>View IAM Policy + </h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <div id="viewPolicyContent"> + <div class="text-center"> + <div class="spinner-border" role="status"> + <span class="visually-hidden">Loading...</span> + </div> + <p class="mt-2">Loading policy...</p> + </div> + </div> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> + <button type="button" class="btn btn-primary" id="editFromViewBtn"> + <i class="fas fa-edit me-1"></i>Edit Policy + </button> + </div> + </div> + </div> + </div> + + <!-- Edit Policy Modal --> + <div class="modal fade" id="editPolicyModal" tabindex="-1" aria-labelledby="editPolicyModalLabel" aria-hidden="true"> + <div class="modal-dialog modal-lg"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="editPolicyModalLabel"> + <i class="fas fa-edit me-2"></i>Edit IAM Policy + </h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <form id="editPolicyForm"> + <div class="mb-3"> + <label for="editPolicyName" class="form-label">Policy Name</label> + <input type="text" class="form-control" id="editPolicyName" name="name" readonly> + <div class="form-text">Policy name cannot be changed</div> + </div> + + <div class="mb-3"> + <label for="editPolicyDocument" class="form-label">Policy Document</label> + <textarea class="form-control" id="editPolicyDocument" name="document" rows="15" required></textarea> + <div class="form-text">Edit the policy document in AWS IAM JSON format</div> + </div> + + <div class="mb-3"> + <div class="row"> + <div class="col-md-6"> + <button type="button" class="btn btn-outline-info btn-sm" onclick="insertSamplePolicyEdit()"> + <i class="fas fa-file-alt me-1"></i>Reset to Sample + </button> + </div> + <div class="col-md-6 text-end"> + <button type="button" class="btn btn-outline-secondary btn-sm" onclick="validateEditPolicyDocument()"> + <i class="fas fa-check me-1"></i>Validate JSON + </button> + </div> + </div> + </div> + </form> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> + <button type="button" class="btn btn-primary" onclick="updatePolicy()"> + <i class="fas fa-save me-1"></i>Save Changes + </button> + </div> + </div> + </div> + </div> + + <!-- JavaScript for Policy Management --> + <script> + // Current policy being viewed/edited + let currentPolicy = null; + + // Event listeners for policy actions + document.addEventListener('DOMContentLoaded', function() { + // View policy buttons + document.querySelectorAll('.view-policy-btn').forEach(button => { + button.addEventListener('click', function() { + const policyName = this.getAttribute('data-policy-name'); + viewPolicy(policyName); + }); + }); + + // Edit policy buttons + document.querySelectorAll('.edit-policy-btn').forEach(button => { + button.addEventListener('click', function() { + const policyName = this.getAttribute('data-policy-name'); + editPolicy(policyName); + }); + }); + + // Delete policy buttons + document.querySelectorAll('.delete-policy-btn').forEach(button => { + button.addEventListener('click', function() { + const policyName = this.getAttribute('data-policy-name'); + deletePolicy(policyName); + }); + }); + + // Edit from view button + document.getElementById('editFromViewBtn').addEventListener('click', function() { + if (currentPolicy) { + const viewModal = bootstrap.Modal.getInstance(document.getElementById('viewPolicyModal')); + if (viewModal) viewModal.hide(); + editPolicy(currentPolicy.name); + } + }); + }); + + function createPolicy() { + const form = document.getElementById('createPolicyForm'); + const formData = new FormData(form); + + const policyName = formData.get('name'); + const policyDocumentText = formData.get('document'); + + if (!policyName || !policyDocumentText) { + alert('Please fill in all required fields'); + return; + } + + let policyDocument; + try { + policyDocument = JSON.parse(policyDocumentText); + } catch (e) { + alert('Invalid JSON in policy document: ' + e.message); + return; + } + + const requestData = { + name: policyName, + document: policyDocument + }; + + fetch('/api/object-store/policies', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestData) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('Policy created successfully!'); + const modal = bootstrap.Modal.getInstance(document.getElementById('createPolicyModal')); + if (modal) modal.hide(); + location.reload(); // Refresh the page to show the new policy + } else { + alert('Error creating policy: ' + (data.error || 'Unknown error')); + } + }) + .catch(error => { + console.error('Error:', error); + alert('Error creating policy: ' + error.message); + }); + } + + function viewPolicy(policyName) { + // Show the modal first + const modal = new bootstrap.Modal(document.getElementById('viewPolicyModal')); + modal.show(); + + // Reset content to loading state + document.getElementById('viewPolicyContent').innerHTML = ` + <div class="text-center"> + <div class="spinner-border" role="status"> + <span class="visually-hidden">Loading...</span> + </div> + <p class="mt-2">Loading policy...</p> + </div> + `; + + // Fetch policy data + fetch('/api/object-store/policies/' + encodeURIComponent(policyName)) + .then(response => { + if (!response.ok) { + throw new Error('Policy not found'); + } + return response.json(); + }) + .then(policy => { + currentPolicy = policy; + displayPolicyDetails(policy); + }) + .catch(error => { + console.error('Error:', error); + document.getElementById('viewPolicyContent').innerHTML = ` + <div class="alert alert-danger" role="alert"> + <i class="fas fa-exclamation-triangle me-2"></i> + Error loading policy: ${error.message} + </div> + `; + }); + } + + function displayPolicyDetails(policy) { + const content = document.getElementById('viewPolicyContent'); + + let statementsHtml = ''; + if (policy.document && policy.document.Statement) { + statementsHtml = policy.document.Statement.map((stmt, index) => ` + <div class="card mb-2"> + <div class="card-header py-2"> + <h6 class="mb-0">Statement ${index + 1}</h6> + </div> + <div class="card-body py-2"> + <div class="row"> + <div class="col-md-6"> + <strong>Effect:</strong> + <span class="badge ${stmt.Effect === 'Allow' ? 'bg-success' : 'bg-danger'}">${stmt.Effect}</span> + </div> + <div class="col-md-6"> + <strong>Actions:</strong> ${Array.isArray(stmt.Action) ? stmt.Action.join(', ') : stmt.Action} + </div> + </div> + <div class="row mt-2"> + <div class="col-12"> + <strong>Resources:</strong> ${Array.isArray(stmt.Resource) ? stmt.Resource.join(', ') : stmt.Resource} + </div> + </div> + </div> + </div> + `).join(''); + } + + content.innerHTML = ` + <div class="row mb-3"> + <div class="col-md-6"> + <strong>Policy Name:</strong> ${policy.name || 'Unknown'} + </div> + <div class="col-md-6"> + <strong>Version:</strong> <span class="badge bg-info">${policy.document?.Version || 'Unknown'}</span> + </div> + </div> + + <div class="mb-3"> + <strong>Statements:</strong> + <div class="mt-2"> + ${statementsHtml || '<p class="text-muted">No statements found</p>'} + </div> + </div> + + <div class="mb-3"> + <strong>Raw Policy Document:</strong> + <pre class="bg-light p-3 border rounded mt-2"><code>${JSON.stringify(policy.document, null, 2)}</code></pre> + </div> + `; + } + + function editPolicy(policyName) { + // Show the modal first + const modal = new bootstrap.Modal(document.getElementById('editPolicyModal')); + modal.show(); + + // Set policy name + document.getElementById('editPolicyName').value = policyName; + document.getElementById('editPolicyDocument').value = 'Loading...'; + + // Fetch policy data + fetch('/api/object-store/policies/' + encodeURIComponent(policyName)) + .then(response => { + if (!response.ok) { + throw new Error('Policy not found'); + } + return response.json(); + }) + .then(policy => { + currentPolicy = policy; + document.getElementById('editPolicyDocument').value = JSON.stringify(policy.document, null, 2); + }) + .catch(error => { + console.error('Error:', error); + alert('Error loading policy for editing: ' + error.message); + const editModal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal')); + if (editModal) editModal.hide(); + }); + } + + function updatePolicy() { + const policyName = document.getElementById('editPolicyName').value; + const policyDocumentText = document.getElementById('editPolicyDocument').value; + + if (!policyName || !policyDocumentText) { + alert('Please fill in all required fields'); + return; + } + + let policyDocument; + try { + policyDocument = JSON.parse(policyDocumentText); + } catch (e) { + alert('Invalid JSON in policy document: ' + e.message); + return; + } + + const requestData = { + document: policyDocument + }; + + fetch('/api/object-store/policies/' + encodeURIComponent(policyName), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestData) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('Policy updated successfully!'); + const modal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal')); + if (modal) modal.hide(); + location.reload(); // Refresh the page to show the updated policy + } else { + alert('Error updating policy: ' + (data.error || 'Unknown error')); + } + }) + .catch(error => { + console.error('Error:', error); + alert('Error updating policy: ' + error.message); + }); + } + + function insertSamplePolicy() { + const samplePolicy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::my-bucket/*" + ] + } + ] + }; + + document.getElementById('policyDocument').value = JSON.stringify(samplePolicy, null, 2); + } + + function insertSamplePolicyEdit() { + const samplePolicy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::my-bucket/*" + ] + } + ] + }; + + document.getElementById('editPolicyDocument').value = JSON.stringify(samplePolicy, null, 2); + } + + function validatePolicyDocument() { + const policyText = document.getElementById('policyDocument').value; + validatePolicyJSON(policyText); + } + + function validateEditPolicyDocument() { + const policyText = document.getElementById('editPolicyDocument').value; + validatePolicyJSON(policyText); + } + + function validatePolicyJSON(policyText) { + if (!policyText) { + alert('Please enter a policy document first'); + return; + } + + try { + const policy = JSON.parse(policyText); + + // Basic validation + if (!policy.Version) { + alert('Policy must have a Version field'); + return; + } + + if (!policy.Statement || !Array.isArray(policy.Statement)) { + alert('Policy must have a Statement array'); + return; + } + + alert('Policy document is valid JSON!'); + } catch (e) { + alert('Invalid JSON: ' + e.message); + } + } + + function deletePolicy(policyName) { + if (confirm('Are you sure you want to delete policy "' + policyName + '"?')) { + fetch('/api/object-store/policies/' + encodeURIComponent(policyName), { + method: 'DELETE' + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('Policy deleted successfully!'); + location.reload(); // Refresh the page + } else { + alert('Error deleting policy: ' + (data.error || 'Unknown error')); + } + }) + .catch(error => { + console.error('Error:', error); + alert('Error deleting policy: ' + error.message); + }); + } + } + </script> +}
\ No newline at end of file diff --git a/weed/admin/view/app/policies_templ.go b/weed/admin/view/app/policies_templ.go new file mode 100644 index 000000000..2e005fb58 --- /dev/null +++ b/weed/admin/view/app/policies_templ.go @@ -0,0 +1,204 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.906 +package app + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" +) + +func Policies(data dash.PoliciesData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-shield-alt me-2\"></i>IAM Policies</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\"><button type=\"button\" class=\"btn btn-sm btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#createPolicyModal\"><i class=\"fas fa-plus me-1\"></i>Create Policy</button></div></div></div><div id=\"policies-content\"><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Policies</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalPolicies)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 34, Col: 74} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></div><div class=\"col-auto\"><i class=\"fas fa-shield-alt fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-success shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-success text-uppercase mb-1\">Active Policies</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalPolicies)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 54, Col: 74} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><div class=\"col-auto\"><i class=\"fas fa-check-circle fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-info shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Last Updated</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("15:04")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 74, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div><div class=\"col-auto\"><i class=\"fas fa-clock fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Policies Table --><div class=\"row\"><div class=\"col-12\"><div class=\"card shadow mb-4\"><div class=\"card-header py-3 d-flex flex-row align-items-center justify-content-between\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-shield-alt me-2\"></i>IAM Policies</h6><div class=\"dropdown no-arrow\"><a class=\"dropdown-toggle\" href=\"#\" role=\"button\" data-bs-toggle=\"dropdown\"><i class=\"fas fa-ellipsis-v fa-sm fa-fw text-gray-400\"></i></a><div class=\"dropdown-menu dropdown-menu-right shadow animated--fade-in\"><div class=\"dropdown-header\">Actions:</div><a class=\"dropdown-item\" href=\"#\"><i class=\"fas fa-download me-2\"></i>Export List</a></div></div></div><div class=\"card-body\"><div class=\"table-responsive\"><table class=\"table table-hover\" width=\"100%\" cellspacing=\"0\"><thead><tr><th>Policy Name</th><th>Version</th><th>Statements</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead> <tbody>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, policy := range data.Policies { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<tr><td><strong>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 123, Col: 68} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</strong></td><td><span class=\"badge bg-info\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Document.Version) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 126, Col: 100} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span></td><td><span class=\"badge bg-secondary\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d statements", len(policy.Document.Statement))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 129, Col: 142} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></td><td><small class=\"text-muted\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(policy.CreatedAt.Format("2006-01-02 15:04")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 132, Col: 118} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</small></td><td><small class=\"text-muted\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(policy.UpdatedAt.Format("2006-01-02 15:04")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 135, Col: 118} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</small></td><td><div class=\"btn-group btn-group-sm\" role=\"group\"><button type=\"button\" class=\"btn btn-outline-info view-policy-btn\" title=\"View Policy\" data-policy-name=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 139, Col: 168} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-primary edit-policy-btn\" title=\"Edit Policy\" data-policy-name=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 142, Col: 171} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><i class=\"fas fa-edit\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger delete-policy-btn\" title=\"Delete Policy\" data-policy-name=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 145, Col: 174} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><i class=\"fas fa-trash\"></i></button></div></td></tr>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(data.Policies) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<tr><td colspan=\"6\" class=\"text-center text-muted py-4\"><i class=\"fas fa-shield-alt fa-3x mb-3 text-muted\"></i><div><h5>No IAM policies found</h5><p>Create your first policy to manage access permissions.</p><button type=\"button\" class=\"btn btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#createPolicyModal\"><i class=\"fas fa-plus me-1\"></i>Create Policy</button></div></td></tr>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</tbody></table></div></div></div></div></div></div><!-- Create Policy Modal --><div class=\"modal fade\" id=\"createPolicyModal\" tabindex=\"-1\" aria-labelledby=\"createPolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createPolicyModalLabel\"><i class=\"fas fa-shield-alt me-2\"></i>Create IAM Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"createPolicyForm\"><div class=\"mb-3\"><label for=\"policyName\" class=\"form-label\">Policy Name</label> <input type=\"text\" class=\"form-control\" id=\"policyName\" name=\"name\" required placeholder=\"e.g., S3ReadOnlyPolicy\"><div class=\"form-text\">Enter a unique name for this policy (alphanumeric and underscores only)</div></div><div class=\"mb-3\"><label for=\"policyDocument\" class=\"form-label\">Policy Document</label> <textarea class=\"form-control\" id=\"policyDocument\" name=\"document\" rows=\"15\" required placeholder=\"Enter IAM policy JSON document...\"></textarea><div class=\"form-text\">Enter the policy document in AWS IAM JSON format</div></div><div class=\"mb-3\"><div class=\"row\"><div class=\"col-md-6\"><button type=\"button\" class=\"btn btn-outline-info btn-sm\" onclick=\"insertSamplePolicy()\"><i class=\"fas fa-file-alt me-1\"></i>Use Sample Policy</button></div><div class=\"col-md-6 text-end\"><button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" onclick=\"validatePolicyDocument()\"><i class=\"fas fa-check me-1\"></i>Validate JSON</button></div></div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"createPolicy()\"><i class=\"fas fa-plus me-1\"></i>Create Policy</button></div></div></div></div><!-- View Policy Modal --><div class=\"modal fade\" id=\"viewPolicyModal\" tabindex=\"-1\" aria-labelledby=\"viewPolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"viewPolicyModalLabel\"><i class=\"fas fa-eye me-2\"></i>View IAM Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><div id=\"viewPolicyContent\"><div class=\"text-center\"><div class=\"spinner-border\" role=\"status\"><span class=\"visually-hidden\">Loading...</span></div><p class=\"mt-2\">Loading policy...</p></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button> <button type=\"button\" class=\"btn btn-primary\" id=\"editFromViewBtn\"><i class=\"fas fa-edit me-1\"></i>Edit Policy</button></div></div></div></div><!-- Edit Policy Modal --><div class=\"modal fade\" id=\"editPolicyModal\" tabindex=\"-1\" aria-labelledby=\"editPolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"editPolicyModalLabel\"><i class=\"fas fa-edit me-2\"></i>Edit IAM Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"editPolicyForm\"><div class=\"mb-3\"><label for=\"editPolicyName\" class=\"form-label\">Policy Name</label> <input type=\"text\" class=\"form-control\" id=\"editPolicyName\" name=\"name\" readonly><div class=\"form-text\">Policy name cannot be changed</div></div><div class=\"mb-3\"><label for=\"editPolicyDocument\" class=\"form-label\">Policy Document</label> <textarea class=\"form-control\" id=\"editPolicyDocument\" name=\"document\" rows=\"15\" required></textarea><div class=\"form-text\">Edit the policy document in AWS IAM JSON format</div></div><div class=\"mb-3\"><div class=\"row\"><div class=\"col-md-6\"><button type=\"button\" class=\"btn btn-outline-info btn-sm\" onclick=\"insertSamplePolicyEdit()\"><i class=\"fas fa-file-alt me-1\"></i>Reset to Sample</button></div><div class=\"col-md-6 text-end\"><button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" onclick=\"validateEditPolicyDocument()\"><i class=\"fas fa-check me-1\"></i>Validate JSON</button></div></div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"updatePolicy()\"><i class=\"fas fa-save me-1\"></i>Save Changes</button></div></div></div></div><!-- JavaScript for Policy Management --><script>\n // Current policy being viewed/edited\n let currentPolicy = null;\n \n // Event listeners for policy actions\n document.addEventListener('DOMContentLoaded', function() {\n // View policy buttons\n document.querySelectorAll('.view-policy-btn').forEach(button => {\n button.addEventListener('click', function() {\n const policyName = this.getAttribute('data-policy-name');\n viewPolicy(policyName);\n });\n });\n \n // Edit policy buttons\n document.querySelectorAll('.edit-policy-btn').forEach(button => {\n button.addEventListener('click', function() {\n const policyName = this.getAttribute('data-policy-name');\n editPolicy(policyName);\n });\n });\n \n // Delete policy buttons\n document.querySelectorAll('.delete-policy-btn').forEach(button => {\n button.addEventListener('click', function() {\n const policyName = this.getAttribute('data-policy-name');\n deletePolicy(policyName);\n });\n });\n \n // Edit from view button\n document.getElementById('editFromViewBtn').addEventListener('click', function() {\n if (currentPolicy) {\n const viewModal = bootstrap.Modal.getInstance(document.getElementById('viewPolicyModal'));\n if (viewModal) viewModal.hide();\n editPolicy(currentPolicy.name);\n }\n });\n });\n \n function createPolicy() {\n const form = document.getElementById('createPolicyForm');\n const formData = new FormData(form);\n \n const policyName = formData.get('name');\n const policyDocumentText = formData.get('document');\n \n if (!policyName || !policyDocumentText) {\n alert('Please fill in all required fields');\n return;\n }\n \n let policyDocument;\n try {\n policyDocument = JSON.parse(policyDocumentText);\n } catch (e) {\n alert('Invalid JSON in policy document: ' + e.message);\n return;\n }\n \n const requestData = {\n name: policyName,\n document: policyDocument\n };\n \n fetch('/api/object-store/policies', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(requestData)\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Policy created successfully!');\n const modal = bootstrap.Modal.getInstance(document.getElementById('createPolicyModal'));\n if (modal) modal.hide();\n location.reload(); // Refresh the page to show the new policy\n } else {\n alert('Error creating policy: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error creating policy: ' + error.message);\n });\n }\n \n function viewPolicy(policyName) {\n // Show the modal first\n const modal = new bootstrap.Modal(document.getElementById('viewPolicyModal'));\n modal.show();\n \n // Reset content to loading state\n document.getElementById('viewPolicyContent').innerHTML = `\n <div class=\"text-center\">\n <div class=\"spinner-border\" role=\"status\">\n <span class=\"visually-hidden\">Loading...</span>\n </div>\n <p class=\"mt-2\">Loading policy...</p>\n </div>\n `;\n \n // Fetch policy data\n fetch('/api/object-store/policies/' + encodeURIComponent(policyName))\n .then(response => {\n if (!response.ok) {\n throw new Error('Policy not found');\n }\n return response.json();\n })\n .then(policy => {\n currentPolicy = policy;\n displayPolicyDetails(policy);\n })\n .catch(error => {\n console.error('Error:', error);\n document.getElementById('viewPolicyContent').innerHTML = `\n <div class=\"alert alert-danger\" role=\"alert\">\n <i class=\"fas fa-exclamation-triangle me-2\"></i>\n Error loading policy: ${error.message}\n </div>\n `;\n });\n }\n \n function displayPolicyDetails(policy) {\n const content = document.getElementById('viewPolicyContent');\n \n let statementsHtml = '';\n if (policy.document && policy.document.Statement) {\n statementsHtml = policy.document.Statement.map((stmt, index) => `\n <div class=\"card mb-2\">\n <div class=\"card-header py-2\">\n <h6 class=\"mb-0\">Statement ${index + 1}</h6>\n </div>\n <div class=\"card-body py-2\">\n <div class=\"row\">\n <div class=\"col-md-6\">\n <strong>Effect:</strong> \n <span class=\"badge ${stmt.Effect === 'Allow' ? 'bg-success' : 'bg-danger'}\">${stmt.Effect}</span>\n </div>\n <div class=\"col-md-6\">\n <strong>Actions:</strong> ${Array.isArray(stmt.Action) ? stmt.Action.join(', ') : stmt.Action}\n </div>\n </div>\n <div class=\"row mt-2\">\n <div class=\"col-12\">\n <strong>Resources:</strong> ${Array.isArray(stmt.Resource) ? stmt.Resource.join(', ') : stmt.Resource}\n </div>\n </div>\n </div>\n </div>\n `).join('');\n }\n \n content.innerHTML = `\n <div class=\"row mb-3\">\n <div class=\"col-md-6\">\n <strong>Policy Name:</strong> ${policy.name || 'Unknown'}\n </div>\n <div class=\"col-md-6\">\n <strong>Version:</strong> <span class=\"badge bg-info\">${policy.document?.Version || 'Unknown'}</span>\n </div>\n </div>\n \n <div class=\"mb-3\">\n <strong>Statements:</strong>\n <div class=\"mt-2\">\n ${statementsHtml || '<p class=\"text-muted\">No statements found</p>'}\n </div>\n </div>\n \n <div class=\"mb-3\">\n <strong>Raw Policy Document:</strong>\n <pre class=\"bg-light p-3 border rounded mt-2\"><code>${JSON.stringify(policy.document, null, 2)}</code></pre>\n </div>\n `;\n }\n \n function editPolicy(policyName) {\n // Show the modal first\n const modal = new bootstrap.Modal(document.getElementById('editPolicyModal'));\n modal.show();\n \n // Set policy name\n document.getElementById('editPolicyName').value = policyName;\n document.getElementById('editPolicyDocument').value = 'Loading...';\n \n // Fetch policy data\n fetch('/api/object-store/policies/' + encodeURIComponent(policyName))\n .then(response => {\n if (!response.ok) {\n throw new Error('Policy not found');\n }\n return response.json();\n })\n .then(policy => {\n currentPolicy = policy;\n document.getElementById('editPolicyDocument').value = JSON.stringify(policy.document, null, 2);\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error loading policy for editing: ' + error.message);\n const editModal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));\n if (editModal) editModal.hide();\n });\n }\n \n function updatePolicy() {\n const policyName = document.getElementById('editPolicyName').value;\n const policyDocumentText = document.getElementById('editPolicyDocument').value;\n \n if (!policyName || !policyDocumentText) {\n alert('Please fill in all required fields');\n return;\n }\n \n let policyDocument;\n try {\n policyDocument = JSON.parse(policyDocumentText);\n } catch (e) {\n alert('Invalid JSON in policy document: ' + e.message);\n return;\n }\n \n const requestData = {\n document: policyDocument\n };\n \n fetch('/api/object-store/policies/' + encodeURIComponent(policyName), {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(requestData)\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Policy updated successfully!');\n const modal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));\n if (modal) modal.hide();\n location.reload(); // Refresh the page to show the updated policy\n } else {\n alert('Error updating policy: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error updating policy: ' + error.message);\n });\n }\n \n function insertSamplePolicy() {\n const samplePolicy = {\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"s3:GetObject\",\n \"s3:PutObject\"\n ],\n \"Resource\": [\n \"arn:aws:s3:::my-bucket/*\"\n ]\n }\n ]\n };\n \n document.getElementById('policyDocument').value = JSON.stringify(samplePolicy, null, 2);\n }\n \n function insertSamplePolicyEdit() {\n const samplePolicy = {\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"s3:GetObject\",\n \"s3:PutObject\"\n ],\n \"Resource\": [\n \"arn:aws:s3:::my-bucket/*\"\n ]\n }\n ]\n };\n \n document.getElementById('editPolicyDocument').value = JSON.stringify(samplePolicy, null, 2);\n }\n \n function validatePolicyDocument() {\n const policyText = document.getElementById('policyDocument').value;\n validatePolicyJSON(policyText);\n }\n \n function validateEditPolicyDocument() {\n const policyText = document.getElementById('editPolicyDocument').value;\n validatePolicyJSON(policyText);\n }\n \n function validatePolicyJSON(policyText) {\n if (!policyText) {\n alert('Please enter a policy document first');\n return;\n }\n \n try {\n const policy = JSON.parse(policyText);\n \n // Basic validation\n if (!policy.Version) {\n alert('Policy must have a Version field');\n return;\n }\n \n if (!policy.Statement || !Array.isArray(policy.Statement)) {\n alert('Policy must have a Statement array');\n return;\n }\n \n alert('Policy document is valid JSON!');\n } catch (e) {\n alert('Invalid JSON: ' + e.message);\n }\n }\n \n function deletePolicy(policyName) {\n if (confirm('Are you sure you want to delete policy \"' + policyName + '\"?')) {\n fetch('/api/object-store/policies/' + encodeURIComponent(policyName), {\n method: 'DELETE'\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Policy deleted successfully!');\n location.reload(); // Refresh the page\n } else {\n alert('Error deleting policy: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error deleting policy: ' + error.message);\n });\n }\n }\n </script>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/weed/admin/view/app/s3_buckets.templ b/weed/admin/view/app/s3_buckets.templ index d6625f5e8..1afafb294 100644 --- a/weed/admin/view/app/s3_buckets.templ +++ b/weed/admin/view/app/s3_buckets.templ @@ -187,11 +187,12 @@ templ S3Buckets(data dash.S3BucketsData) { title="Browse Files"> <i class="fas fa-folder-open"></i> </a> - <a href={templ.SafeURL(fmt.Sprintf("/s3/buckets/%s", bucket.Name))} - class="btn btn-outline-primary btn-sm" - title="View Details"> + <button type="button" + class="btn btn-outline-primary btn-sm view-details-btn" + data-bucket-name={bucket.Name} + title="View Details"> <i class="fas fa-eye"></i> - </a> + </button> <button type="button" class="btn btn-outline-warning btn-sm quota-btn" data-bucket-name={bucket.Name} @@ -442,6 +443,33 @@ templ S3Buckets(data dash.S3BucketsData) { </div> </div> + <!-- Bucket Details Modal --> + <div class="modal fade" id="bucketDetailsModal" tabindex="-1" aria-labelledby="bucketDetailsModalLabel" aria-hidden="true"> + <div class="modal-dialog modal-lg"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="bucketDetailsModalLabel"> + <i class="fas fa-cube me-2"></i>Bucket Details + </h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <div id="bucketDetailsContent"> + <div class="text-center py-4"> + <div class="spinner-border text-primary" role="status"> + <span class="visually-hidden">Loading...</span> + </div> + <div class="mt-2">Loading bucket details...</div> + </div> + </div> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> + </div> + </div> + </div> + </div> + <!-- JavaScript for bucket management --> <script> document.addEventListener('DOMContentLoaded', function() { @@ -504,7 +532,12 @@ templ S3Buckets(data dash.S3BucketsData) { alert('Error creating bucket: ' + data.error); } else { alert('Bucket created successfully!'); - location.reload(); + // Properly close the modal before reloading + const createModal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal')); + if (createModal) { + createModal.hide(); + } + setTimeout(() => location.reload(), 500); } }) .catch(error => { @@ -514,16 +547,41 @@ templ S3Buckets(data dash.S3BucketsData) { }); // Handle delete bucket + let deleteModalInstance = null; document.querySelectorAll('.delete-bucket-btn').forEach(button => { button.addEventListener('click', function() { const bucketName = this.dataset.bucketName; document.getElementById('deleteBucketName').textContent = bucketName; window.currentBucketToDelete = bucketName; - new bootstrap.Modal(document.getElementById('deleteBucketModal')).show(); + + // Dispose of existing modal instance if it exists + if (deleteModalInstance) { + deleteModalInstance.dispose(); + } + + // Create new modal instance + deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal')); + deleteModalInstance.show(); + }); + }); + + // Add event listener to properly dispose of delete modal when hidden + document.getElementById('deleteBucketModal').addEventListener('hidden.bs.modal', function() { + if (deleteModalInstance) { + deleteModalInstance.dispose(); + deleteModalInstance = null; + } + // Force remove any remaining backdrops + document.querySelectorAll('.modal-backdrop').forEach(backdrop => { + backdrop.remove(); }); + // Ensure body classes are removed + document.body.classList.remove('modal-open'); + document.body.style.removeProperty('padding-right'); }); // Handle quota management + let quotaModalInstance = null; document.querySelectorAll('.quota-btn').forEach(button => { button.addEventListener('click', function() { const bucketName = this.dataset.bucketName; @@ -538,10 +596,33 @@ templ S3Buckets(data dash.S3BucketsData) { document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none'; window.currentBucketToUpdate = bucketName; - new bootstrap.Modal(document.getElementById('manageQuotaModal')).show(); + + // Dispose of existing modal instance if it exists + if (quotaModalInstance) { + quotaModalInstance.dispose(); + } + + // Create new modal instance + quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal')); + quotaModalInstance.show(); }); }); + // Add event listener to properly dispose of quota modal when hidden + document.getElementById('manageQuotaModal').addEventListener('hidden.bs.modal', function() { + if (quotaModalInstance) { + quotaModalInstance.dispose(); + quotaModalInstance = null; + } + // Force remove any remaining backdrops + document.querySelectorAll('.modal-backdrop').forEach(backdrop => { + backdrop.remove(); + }); + // Ensure body classes are removed + document.body.classList.remove('modal-open'); + document.body.style.removeProperty('padding-right'); + }); + // Handle quota form submission document.getElementById('quotaForm').addEventListener('submit', function(e) { e.preventDefault(); @@ -567,7 +648,11 @@ templ S3Buckets(data dash.S3BucketsData) { alert('Error updating quota: ' + data.error); } else { alert('Quota updated successfully!'); - location.reload(); + // Properly close the modal before reloading + if (quotaModalInstance) { + quotaModalInstance.hide(); + } + setTimeout(() => location.reload(), 500); } }) .catch(error => { @@ -580,6 +665,74 @@ templ S3Buckets(data dash.S3BucketsData) { document.getElementById('quotaEnabled').addEventListener('change', function() { document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none'; }); + + // Handle view details button + let detailsModalInstance = null; + document.querySelectorAll('.view-details-btn').forEach(button => { + button.addEventListener('click', function() { + const bucketName = this.dataset.bucketName; + + // Update modal title + document.getElementById('bucketDetailsModalLabel').innerHTML = + '<i class="fas fa-cube me-2"></i>Bucket Details - ' + bucketName; + + // Show loading spinner + document.getElementById('bucketDetailsContent').innerHTML = + '<div class="text-center py-4">' + + '<div class="spinner-border text-primary" role="status">' + + '<span class="visually-hidden">Loading...</span>' + + '</div>' + + '<div class="mt-2">Loading bucket details...</div>' + + '</div>'; + + // Dispose of existing modal instance if it exists + if (detailsModalInstance) { + detailsModalInstance.dispose(); + } + + // Create new modal instance + detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal')); + detailsModalInstance.show(); + + // Fetch bucket details + fetch('/api/s3/buckets/' + bucketName) + .then(response => response.json()) + .then(data => { + if (data.error) { + document.getElementById('bucketDetailsContent').innerHTML = + '<div class="alert alert-danger">' + + '<i class="fas fa-exclamation-triangle me-2"></i>' + + 'Error loading bucket details: ' + data.error + + '</div>'; + } else { + displayBucketDetails(data); + } + }) + .catch(error => { + console.error('Error fetching bucket details:', error); + document.getElementById('bucketDetailsContent').innerHTML = + '<div class="alert alert-danger">' + + '<i class="fas fa-exclamation-triangle me-2"></i>' + + 'Error loading bucket details: ' + error.message + + '</div>'; + }); + }); + }); + + // Add event listener to properly dispose of details modal when hidden + document.getElementById('bucketDetailsModal').addEventListener('hidden.bs.modal', function() { + if (detailsModalInstance) { + detailsModalInstance.dispose(); + detailsModalInstance = null; + } + // Force remove any remaining backdrops + document.querySelectorAll('.modal-backdrop').forEach(backdrop => { + backdrop.remove(); + }); + // Ensure body classes are removed + document.body.classList.remove('modal-open'); + document.body.style.removeProperty('padding-right'); + }); }); function deleteBucket() { @@ -595,7 +748,11 @@ templ S3Buckets(data dash.S3BucketsData) { alert('Error deleting bucket: ' + data.error); } else { alert('Bucket deleted successfully!'); - location.reload(); + // Properly close the modal before reloading + if (deleteModalInstance) { + deleteModalInstance.hide(); + } + setTimeout(() => location.reload(), 500); } }) .catch(error => { @@ -604,6 +761,128 @@ templ S3Buckets(data dash.S3BucketsData) { }); } + function displayBucketDetails(data) { + const bucket = data.bucket; + const objects = data.objects || []; + + // Helper function to format bytes + function formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + // Helper function to format date + function formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleString(); + } + + // Generate objects table + let objectsTable = ''; + if (objects.length > 0) { + objectsTable = '<div class="table-responsive">' + + '<table class="table table-sm table-striped">' + + '<thead>' + + '<tr>' + + '<th>Object Key</th>' + + '<th>Size</th>' + + '<th>Last Modified</th>' + + '<th>Storage Class</th>' + + '</tr>' + + '</thead>' + + '<tbody>' + + objects.map(obj => + '<tr>' + + '<td><i class="fas fa-file me-1"></i>' + obj.key + '</td>' + + '<td>' + formatBytes(obj.size) + '</td>' + + '<td>' + formatDate(obj.last_modified) + '</td>' + + '<td><span class="badge bg-primary">' + obj.storage_class + '</span></td>' + + '</tr>' + ).join('') + + '</tbody>' + + '</table>' + + '</div>'; + } else { + objectsTable = '<div class="text-center py-4 text-muted">' + + '<i class="fas fa-file fa-3x mb-3"></i>' + + '<div>No objects found in this bucket</div>' + + '</div>'; + } + + const content = '<div class="row">' + + '<div class="col-md-6">' + + '<h6><i class="fas fa-info-circle me-2"></i>Bucket Information</h6>' + + '<table class="table table-sm">' + + '<tr>' + + '<td><strong>Name:</strong></td>' + + '<td>' + bucket.name + '</td>' + + '</tr>' + + '<tr>' + + '<td><strong>Created:</strong></td>' + + '<td>' + formatDate(bucket.created_at) + '</td>' + + '</tr>' + + '<tr>' + + '<td><strong>Last Modified:</strong></td>' + + '<td>' + formatDate(bucket.last_modified) + '</td>' + + '</tr>' + + '<tr>' + + '<td><strong>Total Size:</strong></td>' + + '<td>' + formatBytes(bucket.size) + '</td>' + + '</tr>' + + '<tr>' + + '<td><strong>Object Count:</strong></td>' + + '<td>' + bucket.object_count + '</td>' + + '</tr>' + + '</table>' + + '</div>' + + '<div class="col-md-6">' + + '<h6><i class="fas fa-cogs me-2"></i>Configuration</h6>' + + '<table class="table table-sm">' + + '<tr>' + + '<td><strong>Quota:</strong></td>' + + '<td>' + + (bucket.quota_enabled ? + '<span class="badge bg-success">' + formatBytes(bucket.quota) + '</span>' : + '<span class="badge bg-secondary">Disabled</span>' + ) + + '</td>' + + '</tr>' + + '<tr>' + + '<td><strong>Versioning:</strong></td>' + + '<td>' + + (bucket.versioning_enabled ? + '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Enabled</span>' : + '<span class="badge bg-secondary"><i class="fas fa-times me-1"></i>Disabled</span>' + ) + + '</td>' + + '</tr>' + + '<tr>' + + '<td><strong>Object Lock:</strong></td>' + + '<td>' + + (bucket.object_lock_enabled ? + '<span class="badge bg-warning"><i class="fas fa-lock me-1"></i>Enabled</span>' + + '<br><small class="text-muted">' + bucket.object_lock_mode + ' • ' + bucket.object_lock_duration + ' days</small>' : + '<span class="badge bg-secondary"><i class="fas fa-unlock me-1"></i>Disabled</span>' + ) + + '</td>' + + '</tr>' + + '</table>' + + '</div>' + + '</div>' + + '<hr>' + + '<div class="row">' + + '<div class="col-12">' + + '<h6><i class="fas fa-list me-2"></i>Objects (' + objects.length + ')</h6>' + + objectsTable + + '</div>' + + '</div>'; + + document.getElementById('bucketDetailsContent').innerHTML = content; + } + function exportBucketList() { // Simple CSV export const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => { @@ -624,7 +903,7 @@ templ S3Buckets(data dash.S3BucketsData) { const csvContent = "data:text/csv;charset=utf-8," + "Name,Created,Objects,Size,Quota,Versioning,Object Lock\n" + - buckets.map(b => `"${b.name}","${b.created}","${b.objects}","${b.size}","${b.quota}","${b.versioning}","${b.objectLock}"`).join("\n"); + buckets.map(b => '"' + b.name + '","' + b.created + '","' + b.objects + '","' + b.size + '","' + b.quota + '","' + b.versioning + '","' + b.objectLock + '"').join("\n"); const encodedUri = encodeURI(csvContent); const link = document.createElement("a"); diff --git a/weed/admin/view/app/s3_buckets_templ.go b/weed/admin/view/app/s3_buckets_templ.go index 0912a51c5..6edb5d371 100644 --- a/weed/admin/view/app/s3_buckets_templ.go +++ b/weed/admin/view/app/s3_buckets_templ.go @@ -290,27 +290,27 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"btn btn-outline-success btn-sm\" title=\"Browse Files\"><i class=\"fas fa-folder-open\"></i></a> <a href=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"btn btn-outline-success btn-sm\" title=\"Browse Files\"><i class=\"fas fa-folder-open\"></i></a> <button type=\"button\" class=\"btn btn-outline-primary btn-sm view-details-btn\" data-bucket-name=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 templ.SafeURL - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/s3/buckets/%s", bucket.Name))) + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, 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: 190, Col: 118} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 192, Col: 89} } _, 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, 30, "\" class=\"btn btn-outline-primary btn-sm\" 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=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-warning btn-sm quota-btn\" data-bucket-name=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, 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: 197, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 198, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -323,7 +323,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", getQuotaInMB(bucket.Quota))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 198, Col: 125} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 199, Col: 125} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -336,7 +336,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, 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: 199, Col: 118} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 200, Col: 118} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -349,7 +349,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, 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: 205, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 206, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -373,13 +373,13 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { var templ_7745c5c3_Var22 string templ_7745c5c3_Var22, 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: 242, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 243, Col: 81} } _, 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, 37, "</small></div></div></div><!-- Create Bucket Modal --><div class=\"modal fade\" id=\"createBucketModal\" tabindex=\"-1\" aria-labelledby=\"createBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createBucketModalLabel\"><i class=\"fas fa-plus me-2\"></i>Create New S3 Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"createBucketForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label for=\"bucketName\" class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"bucketName\" name=\"name\" placeholder=\"my-bucket-name\" required pattern=\"[a-z0-9.-]+\" title=\"Bucket name must contain only lowercase letters, numbers, dots, and hyphens\"><div class=\"form-text\">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 class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableVersioning\" name=\"versioning_enabled\"> <label class=\"form-check-label\" for=\"enableVersioning\">Enable Object Versioning</label></div><div class=\"form-text\">Keep multiple versions of objects in this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableObjectLock\" name=\"object_lock_enabled\"> <label class=\"form-check-label\" for=\"enableObjectLock\">Enable Object Lock</label></div><div class=\"form-text\">Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.</div></div><div class=\"mb-3\" id=\"objectLockSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-6\"><label for=\"objectLockMode\" class=\"form-label\">Object Lock Mode</label> <select class=\"form-select\" id=\"objectLockMode\" name=\"object_lock_mode\"><option value=\"GOVERNANCE\" selected>Governance</option> <option value=\"COMPLIANCE\">Compliance</option></select><div class=\"form-text\">Governance allows override with special permissions, Compliance is immutable.</div></div><div class=\"col-md-6\"><label for=\"objectLockDuration\" class=\"form-label\">Default Retention (days)</label> <input type=\"number\" class=\"form-control\" id=\"objectLockDuration\" name=\"object_lock_duration\" placeholder=\"30\" min=\"1\" max=\"36500\" step=\"1\"><div class=\"form-text\">Default retention period for new objects (1-36500 days).</div></div></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-primary\"><i class=\"fas fa-plus me-1\"></i>Create Bucket</button></div></form></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteBucketModal\" tabindex=\"-1\" aria-labelledby=\"deleteBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"deleteBucketModalLabel\"><i class=\"fas fa-exclamation-triangle me-2 text-warning\"></i>Delete Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the bucket <strong id=\"deleteBucketName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-exclamation-triangle me-2\"></i> <strong>Warning:</strong> This action cannot be undone. All objects in the bucket will be permanently deleted.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" onclick=\"deleteBucket()\"><i class=\"fas fa-trash me-1\"></i>Delete Bucket</button></div></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><!-- JavaScript for bucket management --><script>\n document.addEventListener('DOMContentLoaded', function() {\n const quotaCheckbox = document.getElementById('enableQuota');\n const quotaSettings = document.getElementById('quotaSettings');\n const versioningCheckbox = document.getElementById('enableVersioning');\n const objectLockCheckbox = document.getElementById('enableObjectLock');\n const objectLockSettings = document.getElementById('objectLockSettings');\n const createBucketForm = document.getElementById('createBucketForm');\n\n // Toggle quota settings\n quotaCheckbox.addEventListener('change', function() {\n quotaSettings.style.display = this.checked ? 'block' : 'none';\n });\n\n // Toggle object lock settings and automatically enable versioning\n objectLockCheckbox.addEventListener('change', function() {\n objectLockSettings.style.display = this.checked ? 'block' : 'none';\n if (this.checked) {\n versioningCheckbox.checked = true;\n versioningCheckbox.disabled = true;\n } else {\n versioningCheckbox.disabled = false;\n }\n });\n\n // Handle form submission\n createBucketForm.addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const data = {\n name: formData.get('name'),\n region: formData.get('region') || '',\n quota_size: quotaCheckbox.checked ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: quotaCheckbox.checked,\n versioning_enabled: versioningCheckbox.checked,\n object_lock_enabled: objectLockCheckbox.checked,\n object_lock_mode: formData.get('object_lock_mode') || 'GOVERNANCE',\n object_lock_duration: objectLockCheckbox.checked ? parseInt(formData.get('object_lock_duration')) || 30 : 0\n };\n\n // Validate object lock settings\n if (data.object_lock_enabled && data.object_lock_duration <= 0) {\n alert('Please enter a valid retention duration for object lock.');\n return;\n }\n\n fetch('/api/s3/buckets', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error creating bucket: ' + data.error);\n } else {\n alert('Bucket created successfully!');\n location.reload();\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error creating bucket: ' + error.message);\n });\n });\n\n // Handle delete bucket\n document.querySelectorAll('.delete-bucket-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n document.getElementById('deleteBucketName').textContent = bucketName;\n window.currentBucketToDelete = bucketName;\n new bootstrap.Modal(document.getElementById('deleteBucketModal')).show();\n });\n });\n\n // Handle quota management\n document.querySelectorAll('.quota-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n const currentQuota = parseInt(this.dataset.currentQuota);\n const quotaEnabled = this.dataset.quotaEnabled === 'true';\n \n document.getElementById('quotaBucketName').value = bucketName;\n document.getElementById('quotaEnabled').checked = quotaEnabled;\n document.getElementById('quotaSizeMB').value = currentQuota;\n \n // Toggle quota size settings\n document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';\n \n window.currentBucketToUpdate = bucketName;\n new bootstrap.Modal(document.getElementById('manageQuotaModal')).show();\n });\n });\n\n // Handle quota form submission\n document.getElementById('quotaForm').addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const enabled = document.getElementById('quotaEnabled').checked;\n const data = {\n quota_size: enabled ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: enabled\n };\n\n fetch(`/api/s3/buckets/${window.currentBucketToUpdate}/quota`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error updating quota: ' + data.error);\n } else {\n alert('Quota updated successfully!');\n location.reload();\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error updating quota: ' + error.message);\n });\n });\n\n // Handle quota enabled checkbox\n document.getElementById('quotaEnabled').addEventListener('change', function() {\n document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';\n });\n });\n\n function deleteBucket() {\n const bucketName = window.currentBucketToDelete;\n if (!bucketName) return;\n\n fetch(`/api/s3/buckets/${bucketName}`, {\n method: 'DELETE'\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error deleting bucket: ' + data.error);\n } else {\n alert('Bucket deleted successfully!');\n location.reload();\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error deleting bucket: ' + error.message);\n });\n }\n\n function exportBucketList() {\n // Simple CSV export\n const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n name: cells[0].textContent.trim(),\n created: cells[1].textContent.trim(),\n objects: cells[2].textContent.trim(),\n size: cells[3].textContent.trim(),\n quota: cells[4].textContent.trim(),\n versioning: cells[5].textContent.trim(),\n objectLock: cells[6].textContent.trim()\n };\n }\n return null;\n }).filter(bucket => bucket !== null);\n\n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Name,Created,Objects,Size,Quota,Versioning,Object Lock\\n\" +\n buckets.map(b => `\"${b.name}\",\"${b.created}\",\"${b.objects}\",\"${b.size}\",\"${b.quota}\",\"${b.versioning}\",\"${b.objectLock}\"`).join(\"\\n\");\n\n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"buckets.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</small></div></div></div><!-- Create Bucket Modal --><div class=\"modal fade\" id=\"createBucketModal\" tabindex=\"-1\" aria-labelledby=\"createBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createBucketModalLabel\"><i class=\"fas fa-plus me-2\"></i>Create New S3 Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"createBucketForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label for=\"bucketName\" class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"bucketName\" name=\"name\" placeholder=\"my-bucket-name\" required pattern=\"[a-z0-9.-]+\" title=\"Bucket name must contain only lowercase letters, numbers, dots, and hyphens\"><div class=\"form-text\">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 class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableVersioning\" name=\"versioning_enabled\"> <label class=\"form-check-label\" for=\"enableVersioning\">Enable Object Versioning</label></div><div class=\"form-text\">Keep multiple versions of objects in this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableObjectLock\" name=\"object_lock_enabled\"> <label class=\"form-check-label\" for=\"enableObjectLock\">Enable Object Lock</label></div><div class=\"form-text\">Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.</div></div><div class=\"mb-3\" id=\"objectLockSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-6\"><label for=\"objectLockMode\" class=\"form-label\">Object Lock Mode</label> <select class=\"form-select\" id=\"objectLockMode\" name=\"object_lock_mode\"><option value=\"GOVERNANCE\" selected>Governance</option> <option value=\"COMPLIANCE\">Compliance</option></select><div class=\"form-text\">Governance allows override with special permissions, Compliance is immutable.</div></div><div class=\"col-md-6\"><label for=\"objectLockDuration\" class=\"form-label\">Default Retention (days)</label> <input type=\"number\" class=\"form-control\" id=\"objectLockDuration\" name=\"object_lock_duration\" placeholder=\"30\" min=\"1\" max=\"36500\" step=\"1\"><div class=\"form-text\">Default retention period for new objects (1-36500 days).</div></div></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-primary\"><i class=\"fas fa-plus me-1\"></i>Create Bucket</button></div></form></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteBucketModal\" tabindex=\"-1\" aria-labelledby=\"deleteBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"deleteBucketModalLabel\"><i class=\"fas fa-exclamation-triangle me-2 text-warning\"></i>Delete Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the bucket <strong id=\"deleteBucketName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-exclamation-triangle me-2\"></i> <strong>Warning:</strong> This action cannot be undone. All objects in the bucket will be permanently deleted.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" onclick=\"deleteBucket()\"><i class=\"fas fa-trash me-1\"></i>Delete Bucket</button></div></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><!-- Bucket Details Modal --><div class=\"modal fade\" id=\"bucketDetailsModal\" tabindex=\"-1\" aria-labelledby=\"bucketDetailsModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"bucketDetailsModalLabel\"><i class=\"fas fa-cube me-2\"></i>Bucket Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><div id=\"bucketDetailsContent\"><div class=\"text-center py-4\"><div class=\"spinner-border text-primary\" role=\"status\"><span class=\"visually-hidden\">Loading...</span></div><div class=\"mt-2\">Loading bucket details...</div></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- JavaScript for bucket management --><script>\n document.addEventListener('DOMContentLoaded', function() {\n const quotaCheckbox = document.getElementById('enableQuota');\n const quotaSettings = document.getElementById('quotaSettings');\n const versioningCheckbox = document.getElementById('enableVersioning');\n const objectLockCheckbox = document.getElementById('enableObjectLock');\n const objectLockSettings = document.getElementById('objectLockSettings');\n const createBucketForm = document.getElementById('createBucketForm');\n\n // Toggle quota settings\n quotaCheckbox.addEventListener('change', function() {\n quotaSettings.style.display = this.checked ? 'block' : 'none';\n });\n\n // Toggle object lock settings and automatically enable versioning\n objectLockCheckbox.addEventListener('change', function() {\n objectLockSettings.style.display = this.checked ? 'block' : 'none';\n if (this.checked) {\n versioningCheckbox.checked = true;\n versioningCheckbox.disabled = true;\n } else {\n versioningCheckbox.disabled = false;\n }\n });\n\n // Handle form submission\n createBucketForm.addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const data = {\n name: formData.get('name'),\n region: formData.get('region') || '',\n quota_size: quotaCheckbox.checked ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: quotaCheckbox.checked,\n versioning_enabled: versioningCheckbox.checked,\n object_lock_enabled: objectLockCheckbox.checked,\n object_lock_mode: formData.get('object_lock_mode') || 'GOVERNANCE',\n object_lock_duration: objectLockCheckbox.checked ? parseInt(formData.get('object_lock_duration')) || 30 : 0\n };\n\n // Validate object lock settings\n if (data.object_lock_enabled && data.object_lock_duration <= 0) {\n alert('Please enter a valid retention duration for object lock.');\n return;\n }\n\n fetch('/api/s3/buckets', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error creating bucket: ' + data.error);\n } else {\n alert('Bucket created successfully!');\n // Properly close the modal before reloading\n const createModal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal'));\n if (createModal) {\n createModal.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error creating bucket: ' + error.message);\n });\n });\n\n // Handle delete bucket\n let deleteModalInstance = null;\n document.querySelectorAll('.delete-bucket-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n document.getElementById('deleteBucketName').textContent = bucketName;\n window.currentBucketToDelete = bucketName;\n \n // Dispose of existing modal instance if it exists\n if (deleteModalInstance) {\n deleteModalInstance.dispose();\n }\n \n // Create new modal instance\n deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));\n deleteModalInstance.show();\n });\n });\n\n // Add event listener to properly dispose of delete modal when hidden\n document.getElementById('deleteBucketModal').addEventListener('hidden.bs.modal', function() {\n if (deleteModalInstance) {\n deleteModalInstance.dispose();\n deleteModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n\n // Handle quota management\n let quotaModalInstance = null;\n document.querySelectorAll('.quota-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n const currentQuota = parseInt(this.dataset.currentQuota);\n const quotaEnabled = this.dataset.quotaEnabled === 'true';\n \n document.getElementById('quotaBucketName').value = bucketName;\n document.getElementById('quotaEnabled').checked = quotaEnabled;\n document.getElementById('quotaSizeMB').value = currentQuota;\n \n // Toggle quota size settings\n document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';\n \n window.currentBucketToUpdate = bucketName;\n \n // Dispose of existing modal instance if it exists\n if (quotaModalInstance) {\n quotaModalInstance.dispose();\n }\n \n // Create new modal instance\n quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));\n quotaModalInstance.show();\n });\n });\n\n // Add event listener to properly dispose of quota modal when hidden\n document.getElementById('manageQuotaModal').addEventListener('hidden.bs.modal', function() {\n if (quotaModalInstance) {\n quotaModalInstance.dispose();\n quotaModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n\n // Handle quota form submission\n document.getElementById('quotaForm').addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const enabled = document.getElementById('quotaEnabled').checked;\n const data = {\n quota_size: enabled ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: enabled\n };\n\n fetch(`/api/s3/buckets/${window.currentBucketToUpdate}/quota`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error updating quota: ' + data.error);\n } else {\n alert('Quota updated successfully!');\n // Properly close the modal before reloading\n if (quotaModalInstance) {\n quotaModalInstance.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error updating quota: ' + error.message);\n });\n });\n\n // Handle quota enabled checkbox\n document.getElementById('quotaEnabled').addEventListener('change', function() {\n document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';\n });\n\n // Handle view details button\n let detailsModalInstance = null;\n document.querySelectorAll('.view-details-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n \n // Update modal title\n document.getElementById('bucketDetailsModalLabel').innerHTML = \n '<i class=\"fas fa-cube me-2\"></i>Bucket Details - ' + bucketName;\n \n // Show loading spinner\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"text-center py-4\">' +\n '<div class=\"spinner-border text-primary\" role=\"status\">' +\n '<span class=\"visually-hidden\">Loading...</span>' +\n '</div>' +\n '<div class=\"mt-2\">Loading bucket details...</div>' +\n '</div>';\n \n // Dispose of existing modal instance if it exists\n if (detailsModalInstance) {\n detailsModalInstance.dispose();\n }\n \n // Create new modal instance\n detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));\n detailsModalInstance.show();\n \n // Fetch bucket details\n fetch('/api/s3/buckets/' + bucketName)\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"alert alert-danger\">' +\n '<i class=\"fas fa-exclamation-triangle me-2\"></i>' +\n 'Error loading bucket details: ' + data.error +\n '</div>';\n } else {\n displayBucketDetails(data);\n }\n })\n .catch(error => {\n console.error('Error fetching bucket details:', error);\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"alert alert-danger\">' +\n '<i class=\"fas fa-exclamation-triangle me-2\"></i>' +\n 'Error loading bucket details: ' + error.message +\n '</div>';\n });\n });\n });\n\n // Add event listener to properly dispose of details modal when hidden\n document.getElementById('bucketDetailsModal').addEventListener('hidden.bs.modal', function() {\n if (detailsModalInstance) {\n detailsModalInstance.dispose();\n detailsModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n });\n\n function deleteBucket() {\n const bucketName = window.currentBucketToDelete;\n if (!bucketName) return;\n\n fetch(`/api/s3/buckets/${bucketName}`, {\n method: 'DELETE'\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error deleting bucket: ' + data.error);\n } else {\n alert('Bucket deleted successfully!');\n // Properly close the modal before reloading\n if (deleteModalInstance) {\n deleteModalInstance.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error deleting bucket: ' + error.message);\n });\n }\n\n function displayBucketDetails(data) {\n const bucket = data.bucket;\n const objects = data.objects || [];\n \n // Helper function to format bytes\n function formatBytes(bytes) {\n if (bytes === 0) return '0 Bytes';\n const k = 1024;\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n }\n \n // Helper function to format date\n function formatDate(dateString) {\n const date = new Date(dateString);\n return date.toLocaleString();\n }\n \n // Generate objects table\n let objectsTable = '';\n if (objects.length > 0) {\n objectsTable = '<div class=\"table-responsive\">' +\n '<table class=\"table table-sm table-striped\">' +\n '<thead>' +\n '<tr>' +\n '<th>Object Key</th>' +\n '<th>Size</th>' +\n '<th>Last Modified</th>' +\n '<th>Storage Class</th>' +\n '</tr>' +\n '</thead>' +\n '<tbody>' +\n objects.map(obj => \n '<tr>' +\n '<td><i class=\"fas fa-file me-1\"></i>' + obj.key + '</td>' +\n '<td>' + formatBytes(obj.size) + '</td>' +\n '<td>' + formatDate(obj.last_modified) + '</td>' +\n '<td><span class=\"badge bg-primary\">' + obj.storage_class + '</span></td>' +\n '</tr>'\n ).join('') +\n '</tbody>' +\n '</table>' +\n '</div>';\n } else {\n objectsTable = '<div class=\"text-center py-4 text-muted\">' +\n '<i class=\"fas fa-file fa-3x mb-3\"></i>' +\n '<div>No objects found in this bucket</div>' +\n '</div>';\n }\n \n const content = '<div class=\"row\">' +\n '<div class=\"col-md-6\">' +\n '<h6><i class=\"fas fa-info-circle me-2\"></i>Bucket Information</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr>' +\n '<td><strong>Name:</strong></td>' +\n '<td>' + bucket.name + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Created:</strong></td>' +\n '<td>' + formatDate(bucket.created_at) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Last Modified:</strong></td>' +\n '<td>' + formatDate(bucket.last_modified) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Total Size:</strong></td>' +\n '<td>' + formatBytes(bucket.size) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Object Count:</strong></td>' +\n '<td>' + bucket.object_count + '</td>' +\n '</tr>' +\n '</table>' +\n '</div>' +\n '<div class=\"col-md-6\">' +\n '<h6><i class=\"fas fa-cogs me-2\"></i>Configuration</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr>' +\n '<td><strong>Quota:</strong></td>' +\n '<td>' +\n (bucket.quota_enabled ? \n '<span class=\"badge bg-success\">' + formatBytes(bucket.quota) + '</span>' : \n '<span class=\"badge bg-secondary\">Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Versioning:</strong></td>' +\n '<td>' +\n (bucket.versioning_enabled ? \n '<span class=\"badge bg-success\"><i class=\"fas fa-check me-1\"></i>Enabled</span>' : \n '<span class=\"badge bg-secondary\"><i class=\"fas fa-times me-1\"></i>Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Object Lock:</strong></td>' +\n '<td>' +\n (bucket.object_lock_enabled ? \n '<span class=\"badge bg-warning\"><i class=\"fas fa-lock me-1\"></i>Enabled</span>' +\n '<br><small class=\"text-muted\">' + bucket.object_lock_mode + ' • ' + bucket.object_lock_duration + ' days</small>' : \n '<span class=\"badge bg-secondary\"><i class=\"fas fa-unlock me-1\"></i>Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '</table>' +\n '</div>' +\n '</div>' +\n '<hr>' +\n '<div class=\"row\">' +\n '<div class=\"col-12\">' +\n '<h6><i class=\"fas fa-list me-2\"></i>Objects (' + objects.length + ')</h6>' +\n objectsTable +\n '</div>' +\n '</div>';\n \n document.getElementById('bucketDetailsContent').innerHTML = content;\n }\n\n function exportBucketList() {\n // Simple CSV export\n const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n name: cells[0].textContent.trim(),\n created: cells[1].textContent.trim(),\n objects: cells[2].textContent.trim(),\n size: cells[3].textContent.trim(),\n quota: cells[4].textContent.trim(),\n versioning: cells[5].textContent.trim(),\n objectLock: cells[6].textContent.trim()\n };\n }\n return null;\n }).filter(bucket => bucket !== null);\n\n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Name,Created,Objects,Size,Quota,Versioning,Object Lock\\n\" +\n buckets.map(b => '\"' + b.name + '\",\"' + b.created + '\",\"' + b.objects + '\",\"' + b.size + '\",\"' + b.quota + '\",\"' + b.versioning + '\",\"' + b.objectLock + '\"').join(\"\\n\");\n\n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"buckets.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/layout/layout.templ b/weed/admin/view/layout/layout.templ index 2261f1e41..b5e2cefbf 100644 --- a/weed/admin/view/layout/layout.templ +++ b/weed/admin/view/layout/layout.templ @@ -147,6 +147,11 @@ templ Layout(c *gin.Context, content templ.Component) { <i class="fas fa-users me-2"></i>Users </a> </li> + <li class="nav-item"> + <a class="nav-link py-2" href="/object-store/policies"> + <i class="fas fa-shield-alt me-2"></i>Policies + </a> + </li> </ul> </div> </li> diff --git a/weed/admin/view/layout/layout_templ.go b/weed/admin/view/layout/layout_templ.go index c321c7a6b..562faa677 100644 --- a/weed/admin/view/layout/layout_templ.go +++ b/weed/admin/view/layout/layout_templ.go @@ -62,7 +62,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</a><ul class=\"dropdown-menu\"><li><a class=\"dropdown-item\" href=\"/logout\"><i class=\"fas fa-sign-out-alt me-2\"></i>Logout</a></li></ul></li></ul></div></div></header><div class=\"row g-0\"><!-- Sidebar --><div class=\"col-md-3 col-lg-2 d-md-block bg-light sidebar collapse\"><div class=\"position-sticky pt-3\"><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MAIN</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/admin\"><i class=\"fas fa-tachometer-alt me-2\"></i>Dashboard</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#clusterSubmenu\" aria-expanded=\"false\" aria-controls=\"clusterSubmenu\"><i class=\"fas fa-sitemap me-2\"></i>Cluster <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"clusterSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/masters\"><i class=\"fas fa-crown me-2\"></i>Masters</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volume-servers\"><i class=\"fas fa-server me-2\"></i>Volume Servers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/filers\"><i class=\"fas fa-folder-open me-2\"></i>Filers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volumes\"><i class=\"fas fa-database me-2\"></i>Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/collections\"><i class=\"fas fa-layer-group me-2\"></i>Collections</a></li></ul></div></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MANAGEMENT</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/files\"><i class=\"fas fa-folder me-2\"></i>File Browser</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#objectStoreSubmenu\" aria-expanded=\"false\" aria-controls=\"objectStoreSubmenu\"><i class=\"fas fa-cloud me-2\"></i>Object Store <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"objectStoreSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/buckets\"><i class=\"fas fa-cube me-2\"></i>Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/users\"><i class=\"fas fa-users me-2\"></i>Users</a></li></ul></div></li><li class=\"nav-item\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</a><ul class=\"dropdown-menu\"><li><a class=\"dropdown-item\" href=\"/logout\"><i class=\"fas fa-sign-out-alt me-2\"></i>Logout</a></li></ul></li></ul></div></div></header><div class=\"row g-0\"><!-- Sidebar --><div class=\"col-md-3 col-lg-2 d-md-block bg-light sidebar collapse\"><div class=\"position-sticky pt-3\"><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MAIN</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/admin\"><i class=\"fas fa-tachometer-alt me-2\"></i>Dashboard</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#clusterSubmenu\" aria-expanded=\"false\" aria-controls=\"clusterSubmenu\"><i class=\"fas fa-sitemap me-2\"></i>Cluster <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"clusterSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/masters\"><i class=\"fas fa-crown me-2\"></i>Masters</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volume-servers\"><i class=\"fas fa-server me-2\"></i>Volume Servers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/filers\"><i class=\"fas fa-folder-open me-2\"></i>Filers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volumes\"><i class=\"fas fa-database me-2\"></i>Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/collections\"><i class=\"fas fa-layer-group me-2\"></i>Collections</a></li></ul></div></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MANAGEMENT</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/files\"><i class=\"fas fa-folder me-2\"></i>File Browser</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#objectStoreSubmenu\" aria-expanded=\"false\" aria-controls=\"objectStoreSubmenu\"><i class=\"fas fa-cloud me-2\"></i>Object Store <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"objectStoreSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/buckets\"><i class=\"fas fa-cube me-2\"></i>Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/users\"><i class=\"fas fa-users me-2\"></i>Users</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/policies\"><i class=\"fas fa-shield-alt me-2\"></i>Policies</a></li></ul></div></li><li class=\"nav-item\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -153,7 +153,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var3 templ.SafeURL templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 248, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 253, Col: 117} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -188,7 +188,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 249, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 254, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -206,7 +206,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var7 templ.SafeURL templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 252, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 257, Col: 110} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -241,7 +241,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 253, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 258, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -274,7 +274,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var11 templ.SafeURL templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 265, Col: 106} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 270, Col: 106} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -309,7 +309,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 266, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 271, Col: 105} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -370,7 +370,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year())) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 313, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 318, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -383,7 +383,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 313, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 318, Col: 102} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -435,7 +435,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 337, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 342, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -448,7 +448,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 351, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 356, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -466,7 +466,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 358, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 363, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { diff --git a/weed/credential/credential_store.go b/weed/credential/credential_store.go index cd36263dc..6fe5a5da1 100644 --- a/weed/credential/credential_store.go +++ b/weed/credential/credential_store.go @@ -86,5 +86,27 @@ type UserCredentials struct { UpdatedAt time.Time `json:"updatedAt"` } +// PolicyStatement represents a single policy statement in an IAM policy +type PolicyStatement struct { + Effect string `json:"Effect"` + Action []string `json:"Action"` + Resource []string `json:"Resource"` +} + +// PolicyDocument represents an IAM policy document +type PolicyDocument struct { + Version string `json:"Version"` + Statement []*PolicyStatement `json:"Statement"` +} + +// PolicyManager interface for managing IAM policies +type PolicyManager interface { + GetPolicies(ctx context.Context) (map[string]PolicyDocument, error) + CreatePolicy(ctx context.Context, name string, document PolicyDocument) error + UpdatePolicy(ctx context.Context, name string, document PolicyDocument) error + DeletePolicy(ctx context.Context, name string) error + GetPolicy(ctx context.Context, name string) (*PolicyDocument, error) +} + // Stores holds all available credential store implementations var Stores []CredentialStore diff --git a/weed/credential/filer_etc/filer_etc_identity.go b/weed/credential/filer_etc/filer_etc_identity.go new file mode 100644 index 000000000..103c988ff --- /dev/null +++ b/weed/credential/filer_etc/filer_etc_identity.go @@ -0,0 +1,188 @@ +package filer_etc + +import ( + "bytes" + "context" + "fmt" + + "github.com/seaweedfs/seaweedfs/weed/credential" + "github.com/seaweedfs/seaweedfs/weed/filer" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" +) + +func (store *FilerEtcStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) { + s3cfg := &iam_pb.S3ApiConfiguration{} + + err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { + var buf bytes.Buffer + if err := filer.ReadEntry(nil, client, filer.IamConfigDirectory, filer.IamIdentityFile, &buf); err != nil { + if err != filer_pb.ErrNotFound { + return err + } + } + if buf.Len() > 0 { + return filer.ParseS3ConfigurationFromBytes(buf.Bytes(), s3cfg) + } + return nil + }) + + return s3cfg, err +} + +func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error { + return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { + var buf bytes.Buffer + if err := filer.ProtoToText(&buf, config); err != nil { + return fmt.Errorf("failed to marshal configuration: %v", err) + } + return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamIdentityFile, buf.Bytes()) + }) +} + +func (store *FilerEtcStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error { + // Load existing configuration + config, err := store.LoadConfiguration(ctx) + if err != nil { + return fmt.Errorf("failed to load configuration: %v", err) + } + + // Check if user already exists + for _, existingIdentity := range config.Identities { + if existingIdentity.Name == identity.Name { + return credential.ErrUserAlreadyExists + } + } + + // Add new identity + config.Identities = append(config.Identities, identity) + + // Save configuration + return store.SaveConfiguration(ctx, config) +} + +func (store *FilerEtcStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) { + config, err := store.LoadConfiguration(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load configuration: %v", err) + } + + for _, identity := range config.Identities { + if identity.Name == username { + return identity, nil + } + } + + return nil, credential.ErrUserNotFound +} + +func (store *FilerEtcStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error { + config, err := store.LoadConfiguration(ctx) + if err != nil { + return fmt.Errorf("failed to load configuration: %v", err) + } + + // Find and update the user + for i, existingIdentity := range config.Identities { + if existingIdentity.Name == username { + config.Identities[i] = identity + return store.SaveConfiguration(ctx, config) + } + } + + return credential.ErrUserNotFound +} + +func (store *FilerEtcStore) DeleteUser(ctx context.Context, username string) error { + config, err := store.LoadConfiguration(ctx) + if err != nil { + return fmt.Errorf("failed to load configuration: %v", err) + } + + // Find and remove the user + for i, identity := range config.Identities { + if identity.Name == username { + config.Identities = append(config.Identities[:i], config.Identities[i+1:]...) + return store.SaveConfiguration(ctx, config) + } + } + + return credential.ErrUserNotFound +} + +func (store *FilerEtcStore) ListUsers(ctx context.Context) ([]string, error) { + config, err := store.LoadConfiguration(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load configuration: %v", err) + } + + var usernames []string + for _, identity := range config.Identities { + usernames = append(usernames, identity.Name) + } + + return usernames, nil +} + +func (store *FilerEtcStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) { + config, err := store.LoadConfiguration(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load configuration: %v", err) + } + + for _, identity := range config.Identities { + for _, credential := range identity.Credentials { + if credential.AccessKey == accessKey { + return identity, nil + } + } + } + + return nil, credential.ErrAccessKeyNotFound +} + +func (store *FilerEtcStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error { + config, err := store.LoadConfiguration(ctx) + if err != nil { + return fmt.Errorf("failed to load configuration: %v", err) + } + + // Find the user and add the credential + for _, identity := range config.Identities { + if identity.Name == username { + // Check if access key already exists + for _, existingCred := range identity.Credentials { + if existingCred.AccessKey == cred.AccessKey { + return fmt.Errorf("access key %s already exists", cred.AccessKey) + } + } + + identity.Credentials = append(identity.Credentials, cred) + return store.SaveConfiguration(ctx, config) + } + } + + return credential.ErrUserNotFound +} + +func (store *FilerEtcStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error { + config, err := store.LoadConfiguration(ctx) + if err != nil { + return fmt.Errorf("failed to load configuration: %v", err) + } + + // Find the user and remove the credential + for _, identity := range config.Identities { + if identity.Name == username { + for i, cred := range identity.Credentials { + if cred.AccessKey == accessKey { + identity.Credentials = append(identity.Credentials[:i], identity.Credentials[i+1:]...) + return store.SaveConfiguration(ctx, config) + } + } + return credential.ErrAccessKeyNotFound + } + } + + return credential.ErrUserNotFound +} diff --git a/weed/credential/filer_etc/filer_etc_policy.go b/weed/credential/filer_etc/filer_etc_policy.go new file mode 100644 index 000000000..fdd3156ff --- /dev/null +++ b/weed/credential/filer_etc/filer_etc_policy.go @@ -0,0 +1,114 @@ +package filer_etc + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/seaweedfs/seaweedfs/weed/credential" + "github.com/seaweedfs/seaweedfs/weed/filer" + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" +) + +type PoliciesCollection struct { + Policies map[string]credential.PolicyDocument `json:"policies"` +} + +// GetPolicies retrieves all IAM policies from the filer +func (store *FilerEtcStore) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) { + policiesCollection := &PoliciesCollection{ + Policies: make(map[string]credential.PolicyDocument), + } + + // Check if filer client is configured + if store.filerGrpcAddress == "" { + glog.V(1).Infof("Filer client not configured for policy retrieval, returning empty policies") + // Return empty policies if filer client is not configured + return policiesCollection.Policies, nil + } + + err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { + var buf bytes.Buffer + if err := filer.ReadEntry(nil, client, filer.IamConfigDirectory, filer.IamPoliciesFile, &buf); err != nil { + if err == filer_pb.ErrNotFound { + glog.V(1).Infof("Policies file not found at %s/%s, returning empty policies", filer.IamConfigDirectory, filer.IamPoliciesFile) + // If file doesn't exist, return empty collection + return nil + } + return err + } + + if buf.Len() > 0 { + return json.Unmarshal(buf.Bytes(), policiesCollection) + } + return nil + }) + + if err != nil { + return nil, err + } + + return policiesCollection.Policies, nil +} + +// CreatePolicy creates a new IAM policy in the filer +func (store *FilerEtcStore) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { + return store.updatePolicies(ctx, func(policies map[string]credential.PolicyDocument) { + policies[name] = document + }) +} + +// UpdatePolicy updates an existing IAM policy in the filer +func (store *FilerEtcStore) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { + return store.updatePolicies(ctx, func(policies map[string]credential.PolicyDocument) { + policies[name] = document + }) +} + +// DeletePolicy deletes an IAM policy from the filer +func (store *FilerEtcStore) DeletePolicy(ctx context.Context, name string) error { + return store.updatePolicies(ctx, func(policies map[string]credential.PolicyDocument) { + delete(policies, name) + }) +} + +// updatePolicies is a helper method to update policies atomically +func (store *FilerEtcStore) updatePolicies(ctx context.Context, updateFunc func(map[string]credential.PolicyDocument)) error { + // Load existing policies + policies, err := store.GetPolicies(ctx) + if err != nil { + return err + } + + // Apply update + updateFunc(policies) + + // Save back to filer + policiesCollection := &PoliciesCollection{ + Policies: policies, + } + + data, err := json.Marshal(policiesCollection) + if err != nil { + return err + } + + return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { + return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamPoliciesFile, data) + }) +} + +// GetPolicy retrieves a specific IAM policy by name from the filer +func (store *FilerEtcStore) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) { + policies, err := store.GetPolicies(ctx) + if err != nil { + return nil, err + } + + if policy, exists := policies[name]; exists { + return &policy, nil + } + + return nil, nil // Policy not found +} diff --git a/weed/credential/filer_etc/filer_etc_store.go b/weed/credential/filer_etc/filer_etc_store.go index 6951cc103..f8750cb25 100644 --- a/weed/credential/filer_etc/filer_etc_store.go +++ b/weed/credential/filer_etc/filer_etc_store.go @@ -1,15 +1,11 @@ package filer_etc import ( - "bytes" - "context" "fmt" "github.com/seaweedfs/seaweedfs/weed/credential" - "github.com/seaweedfs/seaweedfs/weed/filer" "github.com/seaweedfs/seaweedfs/weed/pb" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" - "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" "github.com/seaweedfs/seaweedfs/weed/util" "google.golang.org/grpc" ) @@ -54,182 +50,6 @@ func (store *FilerEtcStore) withFilerClient(fn func(client filer_pb.SeaweedFiler return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(store.filerGrpcAddress), store.grpcDialOption, fn) } -func (store *FilerEtcStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) { - s3cfg := &iam_pb.S3ApiConfiguration{} - - err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { - var buf bytes.Buffer - if err := filer.ReadEntry(nil, client, filer.IamConfigDirectory, filer.IamIdentityFile, &buf); err != nil { - if err != filer_pb.ErrNotFound { - return err - } - } - if buf.Len() > 0 { - return filer.ParseS3ConfigurationFromBytes(buf.Bytes(), s3cfg) - } - return nil - }) - - return s3cfg, err -} - -func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error { - return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { - var buf bytes.Buffer - if err := filer.ProtoToText(&buf, config); err != nil { - return fmt.Errorf("failed to marshal configuration: %v", err) - } - return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamIdentityFile, buf.Bytes()) - }) -} - -func (store *FilerEtcStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error { - // Load existing configuration - config, err := store.LoadConfiguration(ctx) - if err != nil { - return fmt.Errorf("failed to load configuration: %v", err) - } - - // Check if user already exists - for _, existingIdentity := range config.Identities { - if existingIdentity.Name == identity.Name { - return credential.ErrUserAlreadyExists - } - } - - // Add new identity - config.Identities = append(config.Identities, identity) - - // Save configuration - return store.SaveConfiguration(ctx, config) -} - -func (store *FilerEtcStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) { - config, err := store.LoadConfiguration(ctx) - if err != nil { - return nil, fmt.Errorf("failed to load configuration: %v", err) - } - - for _, identity := range config.Identities { - if identity.Name == username { - return identity, nil - } - } - - return nil, credential.ErrUserNotFound -} - -func (store *FilerEtcStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error { - config, err := store.LoadConfiguration(ctx) - if err != nil { - return fmt.Errorf("failed to load configuration: %v", err) - } - - // Find and update the user - for i, existingIdentity := range config.Identities { - if existingIdentity.Name == username { - config.Identities[i] = identity - return store.SaveConfiguration(ctx, config) - } - } - - return credential.ErrUserNotFound -} - -func (store *FilerEtcStore) DeleteUser(ctx context.Context, username string) error { - config, err := store.LoadConfiguration(ctx) - if err != nil { - return fmt.Errorf("failed to load configuration: %v", err) - } - - // Find and remove the user - for i, identity := range config.Identities { - if identity.Name == username { - config.Identities = append(config.Identities[:i], config.Identities[i+1:]...) - return store.SaveConfiguration(ctx, config) - } - } - - return credential.ErrUserNotFound -} - -func (store *FilerEtcStore) ListUsers(ctx context.Context) ([]string, error) { - config, err := store.LoadConfiguration(ctx) - if err != nil { - return nil, fmt.Errorf("failed to load configuration: %v", err) - } - - var usernames []string - for _, identity := range config.Identities { - usernames = append(usernames, identity.Name) - } - - return usernames, nil -} - -func (store *FilerEtcStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) { - config, err := store.LoadConfiguration(ctx) - if err != nil { - return nil, fmt.Errorf("failed to load configuration: %v", err) - } - - for _, identity := range config.Identities { - for _, credential := range identity.Credentials { - if credential.AccessKey == accessKey { - return identity, nil - } - } - } - - return nil, credential.ErrAccessKeyNotFound -} - -func (store *FilerEtcStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error { - config, err := store.LoadConfiguration(ctx) - if err != nil { - return fmt.Errorf("failed to load configuration: %v", err) - } - - // Find the user and add the credential - for _, identity := range config.Identities { - if identity.Name == username { - // Check if access key already exists - for _, existingCred := range identity.Credentials { - if existingCred.AccessKey == cred.AccessKey { - return fmt.Errorf("access key %s already exists", cred.AccessKey) - } - } - - identity.Credentials = append(identity.Credentials, cred) - return store.SaveConfiguration(ctx, config) - } - } - - return credential.ErrUserNotFound -} - -func (store *FilerEtcStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error { - config, err := store.LoadConfiguration(ctx) - if err != nil { - return fmt.Errorf("failed to load configuration: %v", err) - } - - // Find the user and remove the credential - for _, identity := range config.Identities { - if identity.Name == username { - for i, cred := range identity.Credentials { - if cred.AccessKey == accessKey { - identity.Credentials = append(identity.Credentials[:i], identity.Credentials[i+1:]...) - return store.SaveConfiguration(ctx, config) - } - } - return credential.ErrAccessKeyNotFound - } - } - - return credential.ErrUserNotFound -} - func (store *FilerEtcStore) Shutdown() { // No cleanup needed for file store } diff --git a/weed/credential/memory/memory_identity.go b/weed/credential/memory/memory_identity.go new file mode 100644 index 000000000..191aa5d16 --- /dev/null +++ b/weed/credential/memory/memory_identity.go @@ -0,0 +1,302 @@ +package memory + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/seaweedfs/seaweedfs/weed/credential" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" +) + +func (store *MemoryStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) { + store.mu.RLock() + defer store.mu.RUnlock() + + if !store.initialized { + return nil, fmt.Errorf("store not initialized") + } + + config := &iam_pb.S3ApiConfiguration{} + + // Convert all users to identities + for _, user := range store.users { + // Deep copy the identity to avoid mutation issues + identityCopy := store.deepCopyIdentity(user) + config.Identities = append(config.Identities, identityCopy) + } + + return config, nil +} + +func (store *MemoryStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error { + store.mu.Lock() + defer store.mu.Unlock() + + if !store.initialized { + return fmt.Errorf("store not initialized") + } + + // Clear existing data + store.users = make(map[string]*iam_pb.Identity) + store.accessKeys = make(map[string]string) + + // Add all identities + for _, identity := range config.Identities { + // Deep copy to avoid mutation issues + identityCopy := store.deepCopyIdentity(identity) + store.users[identity.Name] = identityCopy + + // Index access keys + for _, credential := range identity.Credentials { + store.accessKeys[credential.AccessKey] = identity.Name + } + } + + return nil +} + +func (store *MemoryStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error { + store.mu.Lock() + defer store.mu.Unlock() + + if !store.initialized { + return fmt.Errorf("store not initialized") + } + + if _, exists := store.users[identity.Name]; exists { + return credential.ErrUserAlreadyExists + } + + // Check for duplicate access keys + for _, cred := range identity.Credentials { + if _, exists := store.accessKeys[cred.AccessKey]; exists { + return fmt.Errorf("access key %s already exists", cred.AccessKey) + } + } + + // Deep copy to avoid mutation issues + identityCopy := store.deepCopyIdentity(identity) + store.users[identity.Name] = identityCopy + + // Index access keys + for _, cred := range identity.Credentials { + store.accessKeys[cred.AccessKey] = identity.Name + } + + return nil +} + +func (store *MemoryStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) { + store.mu.RLock() + defer store.mu.RUnlock() + + if !store.initialized { + return nil, fmt.Errorf("store not initialized") + } + + user, exists := store.users[username] + if !exists { + return nil, credential.ErrUserNotFound + } + + // Return a deep copy to avoid mutation issues + return store.deepCopyIdentity(user), nil +} + +func (store *MemoryStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error { + store.mu.Lock() + defer store.mu.Unlock() + + if !store.initialized { + return fmt.Errorf("store not initialized") + } + + existingUser, exists := store.users[username] + if !exists { + return credential.ErrUserNotFound + } + + // Remove old access keys from index + for _, cred := range existingUser.Credentials { + delete(store.accessKeys, cred.AccessKey) + } + + // Check for duplicate access keys (excluding current user) + for _, cred := range identity.Credentials { + if existingUsername, exists := store.accessKeys[cred.AccessKey]; exists && existingUsername != username { + return fmt.Errorf("access key %s already exists", cred.AccessKey) + } + } + + // Deep copy to avoid mutation issues + identityCopy := store.deepCopyIdentity(identity) + store.users[username] = identityCopy + + // Re-index access keys + for _, cred := range identity.Credentials { + store.accessKeys[cred.AccessKey] = username + } + + return nil +} + +func (store *MemoryStore) DeleteUser(ctx context.Context, username string) error { + store.mu.Lock() + defer store.mu.Unlock() + + if !store.initialized { + return fmt.Errorf("store not initialized") + } + + user, exists := store.users[username] + if !exists { + return credential.ErrUserNotFound + } + + // Remove access keys from index + for _, cred := range user.Credentials { + delete(store.accessKeys, cred.AccessKey) + } + + // Remove user + delete(store.users, username) + + return nil +} + +func (store *MemoryStore) ListUsers(ctx context.Context) ([]string, error) { + store.mu.RLock() + defer store.mu.RUnlock() + + if !store.initialized { + return nil, fmt.Errorf("store not initialized") + } + + var usernames []string + for username := range store.users { + usernames = append(usernames, username) + } + + return usernames, nil +} + +func (store *MemoryStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) { + store.mu.RLock() + defer store.mu.RUnlock() + + if !store.initialized { + return nil, fmt.Errorf("store not initialized") + } + + username, exists := store.accessKeys[accessKey] + if !exists { + return nil, credential.ErrAccessKeyNotFound + } + + user, exists := store.users[username] + if !exists { + // This should not happen, but handle it gracefully + return nil, credential.ErrUserNotFound + } + + // Return a deep copy to avoid mutation issues + return store.deepCopyIdentity(user), nil +} + +func (store *MemoryStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error { + store.mu.Lock() + defer store.mu.Unlock() + + if !store.initialized { + return fmt.Errorf("store not initialized") + } + + user, exists := store.users[username] + if !exists { + return credential.ErrUserNotFound + } + + // Check if access key already exists + if _, exists := store.accessKeys[cred.AccessKey]; exists { + return fmt.Errorf("access key %s already exists", cred.AccessKey) + } + + // Add credential to user + user.Credentials = append(user.Credentials, &iam_pb.Credential{ + AccessKey: cred.AccessKey, + SecretKey: cred.SecretKey, + }) + + // Index the access key + store.accessKeys[cred.AccessKey] = username + + return nil +} + +func (store *MemoryStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error { + store.mu.Lock() + defer store.mu.Unlock() + + if !store.initialized { + return fmt.Errorf("store not initialized") + } + + user, exists := store.users[username] + if !exists { + return credential.ErrUserNotFound + } + + // Find and remove the credential + var newCredentials []*iam_pb.Credential + found := false + for _, cred := range user.Credentials { + if cred.AccessKey == accessKey { + found = true + // Remove from access key index + delete(store.accessKeys, accessKey) + } else { + newCredentials = append(newCredentials, cred) + } + } + + if !found { + return credential.ErrAccessKeyNotFound + } + + user.Credentials = newCredentials + return nil +} + +// deepCopyIdentity creates a deep copy of an identity to avoid mutation issues +func (store *MemoryStore) deepCopyIdentity(identity *iam_pb.Identity) *iam_pb.Identity { + if identity == nil { + return nil + } + + // Use JSON marshaling/unmarshaling for deep copy + // This is simple and safe for protobuf messages + data, err := json.Marshal(identity) + if err != nil { + // Fallback to shallow copy if JSON fails + return &iam_pb.Identity{ + Name: identity.Name, + Account: identity.Account, + Credentials: identity.Credentials, + Actions: identity.Actions, + } + } + + var copy iam_pb.Identity + if err := json.Unmarshal(data, ©); err != nil { + // Fallback to shallow copy if JSON fails + return &iam_pb.Identity{ + Name: identity.Name, + Account: identity.Account, + Credentials: identity.Credentials, + Actions: identity.Actions, + } + } + + return © +} diff --git a/weed/credential/memory/memory_policy.go b/weed/credential/memory/memory_policy.go new file mode 100644 index 000000000..1c9268958 --- /dev/null +++ b/weed/credential/memory/memory_policy.go @@ -0,0 +1,77 @@ +package memory + +import ( + "context" + "fmt" + + "github.com/seaweedfs/seaweedfs/weed/credential" +) + +// GetPolicies retrieves all IAM policies from memory +func (store *MemoryStore) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) { + store.mu.RLock() + defer store.mu.RUnlock() + + if !store.initialized { + return nil, fmt.Errorf("store not initialized") + } + + // Create a copy of the policies map to avoid mutation issues + policies := make(map[string]credential.PolicyDocument) + for name, doc := range store.policies { + policies[name] = doc + } + + return policies, nil +} + +// GetPolicy retrieves a specific IAM policy by name from memory +func (store *MemoryStore) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) { + store.mu.RLock() + defer store.mu.RUnlock() + + if policy, exists := store.policies[name]; exists { + return &policy, nil + } + + return nil, nil // Policy not found +} + +// CreatePolicy creates a new IAM policy in memory +func (store *MemoryStore) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { + store.mu.Lock() + defer store.mu.Unlock() + + if !store.initialized { + return fmt.Errorf("store not initialized") + } + + store.policies[name] = document + return nil +} + +// UpdatePolicy updates an existing IAM policy in memory +func (store *MemoryStore) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { + store.mu.Lock() + defer store.mu.Unlock() + + if !store.initialized { + return fmt.Errorf("store not initialized") + } + + store.policies[name] = document + return nil +} + +// DeletePolicy deletes an IAM policy from memory +func (store *MemoryStore) DeletePolicy(ctx context.Context, name string) error { + store.mu.Lock() + defer store.mu.Unlock() + + if !store.initialized { + return fmt.Errorf("store not initialized") + } + + delete(store.policies, name) + return nil +} diff --git a/weed/credential/memory/memory_store.go b/weed/credential/memory/memory_store.go index e6117bf48..f0f383c04 100644 --- a/weed/credential/memory/memory_store.go +++ b/weed/credential/memory/memory_store.go @@ -1,9 +1,6 @@ package memory import ( - "context" - "encoding/json" - "fmt" "sync" "github.com/seaweedfs/seaweedfs/weed/credential" @@ -19,8 +16,9 @@ func init() { // This is primarily intended for testing purposes type MemoryStore struct { mu sync.RWMutex - users map[string]*iam_pb.Identity // username -> identity - accessKeys map[string]string // access_key -> username + users map[string]*iam_pb.Identity // username -> identity + accessKeys map[string]string // access_key -> username + policies map[string]credential.PolicyDocument // policy_name -> policy_document initialized bool } @@ -38,313 +36,22 @@ func (store *MemoryStore) Initialize(configuration util.Configuration, prefix st store.users = make(map[string]*iam_pb.Identity) store.accessKeys = make(map[string]string) + store.policies = make(map[string]credential.PolicyDocument) store.initialized = true return nil } -func (store *MemoryStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) { - store.mu.RLock() - defer store.mu.RUnlock() - - if !store.initialized { - return nil, fmt.Errorf("store not initialized") - } - - config := &iam_pb.S3ApiConfiguration{} - - // Convert all users to identities - for _, user := range store.users { - // Deep copy the identity to avoid mutation issues - identityCopy := store.deepCopyIdentity(user) - config.Identities = append(config.Identities, identityCopy) - } - - return config, nil -} - -func (store *MemoryStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error { - store.mu.Lock() - defer store.mu.Unlock() - - if !store.initialized { - return fmt.Errorf("store not initialized") - } - - // Clear existing data - store.users = make(map[string]*iam_pb.Identity) - store.accessKeys = make(map[string]string) - - // Add all identities - for _, identity := range config.Identities { - // Deep copy to avoid mutation issues - identityCopy := store.deepCopyIdentity(identity) - store.users[identity.Name] = identityCopy - - // Index access keys - for _, credential := range identity.Credentials { - store.accessKeys[credential.AccessKey] = identity.Name - } - } - - return nil -} - -func (store *MemoryStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error { - store.mu.Lock() - defer store.mu.Unlock() - - if !store.initialized { - return fmt.Errorf("store not initialized") - } - - if _, exists := store.users[identity.Name]; exists { - return credential.ErrUserAlreadyExists - } - - // Check for duplicate access keys - for _, cred := range identity.Credentials { - if _, exists := store.accessKeys[cred.AccessKey]; exists { - return fmt.Errorf("access key %s already exists", cred.AccessKey) - } - } - - // Deep copy to avoid mutation issues - identityCopy := store.deepCopyIdentity(identity) - store.users[identity.Name] = identityCopy - - // Index access keys - for _, cred := range identity.Credentials { - store.accessKeys[cred.AccessKey] = identity.Name - } - - return nil -} - -func (store *MemoryStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) { - store.mu.RLock() - defer store.mu.RUnlock() - - if !store.initialized { - return nil, fmt.Errorf("store not initialized") - } - - user, exists := store.users[username] - if !exists { - return nil, credential.ErrUserNotFound - } - - // Return a deep copy to avoid mutation issues - return store.deepCopyIdentity(user), nil -} - -func (store *MemoryStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error { - store.mu.Lock() - defer store.mu.Unlock() - - if !store.initialized { - return fmt.Errorf("store not initialized") - } - - existingUser, exists := store.users[username] - if !exists { - return credential.ErrUserNotFound - } - - // Remove old access keys from index - for _, cred := range existingUser.Credentials { - delete(store.accessKeys, cred.AccessKey) - } - - // Check for duplicate access keys (excluding current user) - for _, cred := range identity.Credentials { - if existingUsername, exists := store.accessKeys[cred.AccessKey]; exists && existingUsername != username { - return fmt.Errorf("access key %s already exists", cred.AccessKey) - } - } - - // Deep copy to avoid mutation issues - identityCopy := store.deepCopyIdentity(identity) - store.users[username] = identityCopy - - // Re-index access keys - for _, cred := range identity.Credentials { - store.accessKeys[cred.AccessKey] = username - } - - return nil -} - -func (store *MemoryStore) DeleteUser(ctx context.Context, username string) error { - store.mu.Lock() - defer store.mu.Unlock() - - if !store.initialized { - return fmt.Errorf("store not initialized") - } - - user, exists := store.users[username] - if !exists { - return credential.ErrUserNotFound - } - - // Remove access keys from index - for _, cred := range user.Credentials { - delete(store.accessKeys, cred.AccessKey) - } - - // Remove user - delete(store.users, username) - - return nil -} - -func (store *MemoryStore) ListUsers(ctx context.Context) ([]string, error) { - store.mu.RLock() - defer store.mu.RUnlock() - - if !store.initialized { - return nil, fmt.Errorf("store not initialized") - } - - var usernames []string - for username := range store.users { - usernames = append(usernames, username) - } - - return usernames, nil -} - -func (store *MemoryStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) { - store.mu.RLock() - defer store.mu.RUnlock() - - if !store.initialized { - return nil, fmt.Errorf("store not initialized") - } - - username, exists := store.accessKeys[accessKey] - if !exists { - return nil, credential.ErrAccessKeyNotFound - } - - user, exists := store.users[username] - if !exists { - // This should not happen, but handle it gracefully - return nil, credential.ErrUserNotFound - } - - // Return a deep copy to avoid mutation issues - return store.deepCopyIdentity(user), nil -} - -func (store *MemoryStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error { - store.mu.Lock() - defer store.mu.Unlock() - - if !store.initialized { - return fmt.Errorf("store not initialized") - } - - user, exists := store.users[username] - if !exists { - return credential.ErrUserNotFound - } - - // Check if access key already exists - if _, exists := store.accessKeys[cred.AccessKey]; exists { - return fmt.Errorf("access key %s already exists", cred.AccessKey) - } - - // Add credential to user - user.Credentials = append(user.Credentials, &iam_pb.Credential{ - AccessKey: cred.AccessKey, - SecretKey: cred.SecretKey, - }) - - // Index the access key - store.accessKeys[cred.AccessKey] = username - - return nil -} - -func (store *MemoryStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error { - store.mu.Lock() - defer store.mu.Unlock() - - if !store.initialized { - return fmt.Errorf("store not initialized") - } - - user, exists := store.users[username] - if !exists { - return credential.ErrUserNotFound - } - - // Find and remove the credential - var newCredentials []*iam_pb.Credential - found := false - for _, cred := range user.Credentials { - if cred.AccessKey == accessKey { - found = true - // Remove from access key index - delete(store.accessKeys, accessKey) - } else { - newCredentials = append(newCredentials, cred) - } - } - - if !found { - return credential.ErrAccessKeyNotFound - } - - user.Credentials = newCredentials - return nil -} - func (store *MemoryStore) Shutdown() { store.mu.Lock() defer store.mu.Unlock() - // Clear all data store.users = nil store.accessKeys = nil + store.policies = nil store.initialized = false } -// deepCopyIdentity creates a deep copy of an identity to avoid mutation issues -func (store *MemoryStore) deepCopyIdentity(identity *iam_pb.Identity) *iam_pb.Identity { - if identity == nil { - return nil - } - - // Use JSON marshaling/unmarshaling for deep copy - // This is simple and safe for protobuf messages - data, err := json.Marshal(identity) - if err != nil { - // Fallback to shallow copy if JSON fails - return &iam_pb.Identity{ - Name: identity.Name, - Account: identity.Account, - Credentials: identity.Credentials, - Actions: identity.Actions, - } - } - - var copy iam_pb.Identity - if err := json.Unmarshal(data, ©); err != nil { - // Fallback to shallow copy if JSON fails - return &iam_pb.Identity{ - Name: identity.Name, - Account: identity.Account, - Credentials: identity.Credentials, - Actions: identity.Actions, - } - } - - return © -} - // Reset clears all data in the store (useful for testing) func (store *MemoryStore) Reset() { store.mu.Lock() diff --git a/weed/credential/postgres/postgres_identity.go b/weed/credential/postgres/postgres_identity.go new file mode 100644 index 000000000..ea3627c50 --- /dev/null +++ b/weed/credential/postgres/postgres_identity.go @@ -0,0 +1,446 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "github.com/seaweedfs/seaweedfs/weed/credential" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" +) + +func (store *PostgresStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) { + if !store.configured { + return nil, fmt.Errorf("store not configured") + } + + config := &iam_pb.S3ApiConfiguration{} + + // Query all users + rows, err := store.db.QueryContext(ctx, "SELECT username, email, account_data, actions FROM users") + if err != nil { + return nil, fmt.Errorf("failed to query users: %v", err) + } + defer rows.Close() + + for rows.Next() { + var username, email string + var accountDataJSON, actionsJSON []byte + + if err := rows.Scan(&username, &email, &accountDataJSON, &actionsJSON); err != nil { + return nil, fmt.Errorf("failed to scan user row: %v", err) + } + + identity := &iam_pb.Identity{ + Name: username, + } + + // Parse account data + if len(accountDataJSON) > 0 { + if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil { + return nil, fmt.Errorf("failed to unmarshal account data for user %s: %v", username, err) + } + } + + // Parse actions + if len(actionsJSON) > 0 { + if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil { + return nil, fmt.Errorf("failed to unmarshal actions for user %s: %v", username, err) + } + } + + // Query credentials for this user + credRows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username) + if err != nil { + return nil, fmt.Errorf("failed to query credentials for user %s: %v", username, err) + } + + for credRows.Next() { + var accessKey, secretKey string + if err := credRows.Scan(&accessKey, &secretKey); err != nil { + credRows.Close() + return nil, fmt.Errorf("failed to scan credential row for user %s: %v", username, err) + } + + identity.Credentials = append(identity.Credentials, &iam_pb.Credential{ + AccessKey: accessKey, + SecretKey: secretKey, + }) + } + credRows.Close() + + config.Identities = append(config.Identities, identity) + } + + return config, nil +} + +func (store *PostgresStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error { + if !store.configured { + return fmt.Errorf("store not configured") + } + + // Start transaction + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %v", err) + } + defer tx.Rollback() + + // Clear existing data + if _, err := tx.ExecContext(ctx, "DELETE FROM credentials"); err != nil { + return fmt.Errorf("failed to clear credentials: %v", err) + } + if _, err := tx.ExecContext(ctx, "DELETE FROM users"); err != nil { + return fmt.Errorf("failed to clear users: %v", err) + } + + // Insert all identities + for _, identity := range config.Identities { + // Marshal account data + var accountDataJSON []byte + if identity.Account != nil { + accountDataJSON, err = json.Marshal(identity.Account) + if err != nil { + return fmt.Errorf("failed to marshal account data for user %s: %v", identity.Name, err) + } + } + + // Marshal actions + var actionsJSON []byte + if identity.Actions != nil { + actionsJSON, err = json.Marshal(identity.Actions) + if err != nil { + return fmt.Errorf("failed to marshal actions for user %s: %v", identity.Name, err) + } + } + + // Insert user + _, err := tx.ExecContext(ctx, + "INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)", + identity.Name, "", accountDataJSON, actionsJSON) + if err != nil { + return fmt.Errorf("failed to insert user %s: %v", identity.Name, err) + } + + // Insert credentials + for _, cred := range identity.Credentials { + _, err := tx.ExecContext(ctx, + "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)", + identity.Name, cred.AccessKey, cred.SecretKey) + if err != nil { + return fmt.Errorf("failed to insert credential for user %s: %v", identity.Name, err) + } + } + } + + return tx.Commit() +} + +func (store *PostgresStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error { + if !store.configured { + return fmt.Errorf("store not configured") + } + + // Check if user already exists + var count int + err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", identity.Name).Scan(&count) + if err != nil { + return fmt.Errorf("failed to check user existence: %v", err) + } + if count > 0 { + return credential.ErrUserAlreadyExists + } + + // Start transaction + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %v", err) + } + defer tx.Rollback() + + // Marshal account data + var accountDataJSON []byte + if identity.Account != nil { + accountDataJSON, err = json.Marshal(identity.Account) + if err != nil { + return fmt.Errorf("failed to marshal account data: %v", err) + } + } + + // Marshal actions + var actionsJSON []byte + if identity.Actions != nil { + actionsJSON, err = json.Marshal(identity.Actions) + if err != nil { + return fmt.Errorf("failed to marshal actions: %v", err) + } + } + + // Insert user + _, err = tx.ExecContext(ctx, + "INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)", + identity.Name, "", accountDataJSON, actionsJSON) + if err != nil { + return fmt.Errorf("failed to insert user: %v", err) + } + + // Insert credentials + for _, cred := range identity.Credentials { + _, err = tx.ExecContext(ctx, + "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)", + identity.Name, cred.AccessKey, cred.SecretKey) + if err != nil { + return fmt.Errorf("failed to insert credential: %v", err) + } + } + + return tx.Commit() +} + +func (store *PostgresStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) { + if !store.configured { + return nil, fmt.Errorf("store not configured") + } + + var email string + var accountDataJSON, actionsJSON []byte + + err := store.db.QueryRowContext(ctx, + "SELECT email, account_data, actions FROM users WHERE username = $1", + username).Scan(&email, &accountDataJSON, &actionsJSON) + if err != nil { + if err == sql.ErrNoRows { + return nil, credential.ErrUserNotFound + } + return nil, fmt.Errorf("failed to query user: %v", err) + } + + identity := &iam_pb.Identity{ + Name: username, + } + + // Parse account data + if len(accountDataJSON) > 0 { + if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil { + return nil, fmt.Errorf("failed to unmarshal account data: %v", err) + } + } + + // Parse actions + if len(actionsJSON) > 0 { + if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil { + return nil, fmt.Errorf("failed to unmarshal actions: %v", err) + } + } + + // Query credentials + rows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username) + if err != nil { + return nil, fmt.Errorf("failed to query credentials: %v", err) + } + defer rows.Close() + + for rows.Next() { + var accessKey, secretKey string + if err := rows.Scan(&accessKey, &secretKey); err != nil { + return nil, fmt.Errorf("failed to scan credential: %v", err) + } + + identity.Credentials = append(identity.Credentials, &iam_pb.Credential{ + AccessKey: accessKey, + SecretKey: secretKey, + }) + } + + return identity, nil +} + +func (store *PostgresStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error { + if !store.configured { + return fmt.Errorf("store not configured") + } + + // Start transaction + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %v", err) + } + defer tx.Rollback() + + // Check if user exists + var count int + err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count) + if err != nil { + return fmt.Errorf("failed to check user existence: %v", err) + } + if count == 0 { + return credential.ErrUserNotFound + } + + // Marshal account data + var accountDataJSON []byte + if identity.Account != nil { + accountDataJSON, err = json.Marshal(identity.Account) + if err != nil { + return fmt.Errorf("failed to marshal account data: %v", err) + } + } + + // Marshal actions + var actionsJSON []byte + if identity.Actions != nil { + actionsJSON, err = json.Marshal(identity.Actions) + if err != nil { + return fmt.Errorf("failed to marshal actions: %v", err) + } + } + + // Update user + _, err = tx.ExecContext(ctx, + "UPDATE users SET email = $2, account_data = $3, actions = $4, updated_at = CURRENT_TIMESTAMP WHERE username = $1", + username, "", accountDataJSON, actionsJSON) + if err != nil { + return fmt.Errorf("failed to update user: %v", err) + } + + // Delete existing credentials + _, err = tx.ExecContext(ctx, "DELETE FROM credentials WHERE username = $1", username) + if err != nil { + return fmt.Errorf("failed to delete existing credentials: %v", err) + } + + // Insert new credentials + for _, cred := range identity.Credentials { + _, err = tx.ExecContext(ctx, + "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)", + username, cred.AccessKey, cred.SecretKey) + if err != nil { + return fmt.Errorf("failed to insert credential: %v", err) + } + } + + return tx.Commit() +} + +func (store *PostgresStore) DeleteUser(ctx context.Context, username string) error { + if !store.configured { + return fmt.Errorf("store not configured") + } + + result, err := store.db.ExecContext(ctx, "DELETE FROM users WHERE username = $1", username) + if err != nil { + return fmt.Errorf("failed to delete user: %v", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %v", err) + } + + if rowsAffected == 0 { + return credential.ErrUserNotFound + } + + return nil +} + +func (store *PostgresStore) ListUsers(ctx context.Context) ([]string, error) { + if !store.configured { + return nil, fmt.Errorf("store not configured") + } + + rows, err := store.db.QueryContext(ctx, "SELECT username FROM users ORDER BY username") + if err != nil { + return nil, fmt.Errorf("failed to query users: %v", err) + } + defer rows.Close() + + var usernames []string + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + return nil, fmt.Errorf("failed to scan username: %v", err) + } + usernames = append(usernames, username) + } + + return usernames, nil +} + +func (store *PostgresStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) { + if !store.configured { + return nil, fmt.Errorf("store not configured") + } + + var username string + err := store.db.QueryRowContext(ctx, "SELECT username FROM credentials WHERE access_key = $1", accessKey).Scan(&username) + if err != nil { + if err == sql.ErrNoRows { + return nil, credential.ErrAccessKeyNotFound + } + return nil, fmt.Errorf("failed to query access key: %v", err) + } + + return store.GetUser(ctx, username) +} + +func (store *PostgresStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error { + if !store.configured { + return fmt.Errorf("store not configured") + } + + // Check if user exists + var count int + err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count) + if err != nil { + return fmt.Errorf("failed to check user existence: %v", err) + } + if count == 0 { + return credential.ErrUserNotFound + } + + // Insert credential + _, err = store.db.ExecContext(ctx, + "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)", + username, cred.AccessKey, cred.SecretKey) + if err != nil { + return fmt.Errorf("failed to insert credential: %v", err) + } + + return nil +} + +func (store *PostgresStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error { + if !store.configured { + return fmt.Errorf("store not configured") + } + + result, err := store.db.ExecContext(ctx, + "DELETE FROM credentials WHERE username = $1 AND access_key = $2", + username, accessKey) + if err != nil { + return fmt.Errorf("failed to delete access key: %v", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %v", err) + } + + if rowsAffected == 0 { + // Check if user exists + var count int + err = store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count) + if err != nil { + return fmt.Errorf("failed to check user existence: %v", err) + } + if count == 0 { + return credential.ErrUserNotFound + } + return credential.ErrAccessKeyNotFound + } + + return nil +} diff --git a/weed/credential/postgres/postgres_policy.go b/weed/credential/postgres/postgres_policy.go new file mode 100644 index 000000000..8be2b108c --- /dev/null +++ b/weed/credential/postgres/postgres_policy.go @@ -0,0 +1,130 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/seaweedfs/seaweedfs/weed/credential" +) + +// GetPolicies retrieves all IAM policies from PostgreSQL +func (store *PostgresStore) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) { + if !store.configured { + return nil, fmt.Errorf("store not configured") + } + + policies := make(map[string]credential.PolicyDocument) + + rows, err := store.db.QueryContext(ctx, "SELECT name, document FROM policies") + if err != nil { + return nil, fmt.Errorf("failed to query policies: %v", err) + } + defer rows.Close() + + for rows.Next() { + var name string + var documentJSON []byte + + if err := rows.Scan(&name, &documentJSON); err != nil { + return nil, fmt.Errorf("failed to scan policy row: %v", err) + } + + var document credential.PolicyDocument + if err := json.Unmarshal(documentJSON, &document); err != nil { + return nil, fmt.Errorf("failed to unmarshal policy document for %s: %v", name, err) + } + + policies[name] = document + } + + return policies, nil +} + +// CreatePolicy creates a new IAM policy in PostgreSQL +func (store *PostgresStore) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { + if !store.configured { + return fmt.Errorf("store not configured") + } + + documentJSON, err := json.Marshal(document) + if err != nil { + return fmt.Errorf("failed to marshal policy document: %v", err) + } + + _, err = store.db.ExecContext(ctx, + "INSERT INTO policies (name, document) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET document = $2, updated_at = CURRENT_TIMESTAMP", + name, documentJSON) + if err != nil { + return fmt.Errorf("failed to insert policy: %v", err) + } + + return nil +} + +// UpdatePolicy updates an existing IAM policy in PostgreSQL +func (store *PostgresStore) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error { + if !store.configured { + return fmt.Errorf("store not configured") + } + + documentJSON, err := json.Marshal(document) + if err != nil { + return fmt.Errorf("failed to marshal policy document: %v", err) + } + + result, err := store.db.ExecContext(ctx, + "UPDATE policies SET document = $2, updated_at = CURRENT_TIMESTAMP WHERE name = $1", + name, documentJSON) + if err != nil { + return fmt.Errorf("failed to update policy: %v", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %v", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("policy %s not found", name) + } + + return nil +} + +// DeletePolicy deletes an IAM policy from PostgreSQL +func (store *PostgresStore) DeletePolicy(ctx context.Context, name string) error { + if !store.configured { + return fmt.Errorf("store not configured") + } + + result, err := store.db.ExecContext(ctx, "DELETE FROM policies WHERE name = $1", name) + if err != nil { + return fmt.Errorf("failed to delete policy: %v", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %v", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("policy %s not found", name) + } + + return nil +} + +// GetPolicy retrieves a specific IAM policy by name from PostgreSQL +func (store *PostgresStore) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) { + policies, err := store.GetPolicies(ctx) + if err != nil { + return nil, err + } + + if policy, exists := policies[name]; exists { + return &policy, nil + } + + return nil, nil // Policy not found +} diff --git a/weed/credential/postgres/postgres_store.go b/weed/credential/postgres/postgres_store.go index 0d75ad8c0..40d200668 100644 --- a/weed/credential/postgres/postgres_store.go +++ b/weed/credential/postgres/postgres_store.go @@ -1,14 +1,11 @@ package postgres import ( - "context" "database/sql" - "encoding/json" "fmt" "time" "github.com/seaweedfs/seaweedfs/weed/credential" - "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" "github.com/seaweedfs/seaweedfs/weed/util" _ "github.com/lib/pq" @@ -114,6 +111,17 @@ func (store *PostgresStore) createTables() error { CREATE INDEX IF NOT EXISTS idx_credentials_access_key ON credentials(access_key); ` + // Create policies table + policiesTable := ` + CREATE TABLE IF NOT EXISTS policies ( + name VARCHAR(255) PRIMARY KEY, + document JSONB NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_policies_name ON policies(name); + ` + // Execute table creation if _, err := store.db.Exec(usersTable); err != nil { return fmt.Errorf("failed to create users table: %v", err) @@ -123,439 +131,8 @@ func (store *PostgresStore) createTables() error { return fmt.Errorf("failed to create credentials table: %v", err) } - return nil -} - -func (store *PostgresStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) { - if !store.configured { - return nil, fmt.Errorf("store not configured") - } - - config := &iam_pb.S3ApiConfiguration{} - - // Query all users - rows, err := store.db.QueryContext(ctx, "SELECT username, email, account_data, actions FROM users") - if err != nil { - return nil, fmt.Errorf("failed to query users: %v", err) - } - defer rows.Close() - - for rows.Next() { - var username, email string - var accountDataJSON, actionsJSON []byte - - if err := rows.Scan(&username, &email, &accountDataJSON, &actionsJSON); err != nil { - return nil, fmt.Errorf("failed to scan user row: %v", err) - } - - identity := &iam_pb.Identity{ - Name: username, - } - - // Parse account data - if len(accountDataJSON) > 0 { - if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil { - return nil, fmt.Errorf("failed to unmarshal account data for user %s: %v", username, err) - } - } - - // Parse actions - if len(actionsJSON) > 0 { - if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil { - return nil, fmt.Errorf("failed to unmarshal actions for user %s: %v", username, err) - } - } - - // Query credentials for this user - credRows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username) - if err != nil { - return nil, fmt.Errorf("failed to query credentials for user %s: %v", username, err) - } - - for credRows.Next() { - var accessKey, secretKey string - if err := credRows.Scan(&accessKey, &secretKey); err != nil { - credRows.Close() - return nil, fmt.Errorf("failed to scan credential row for user %s: %v", username, err) - } - - identity.Credentials = append(identity.Credentials, &iam_pb.Credential{ - AccessKey: accessKey, - SecretKey: secretKey, - }) - } - credRows.Close() - - config.Identities = append(config.Identities, identity) - } - - return config, nil -} - -func (store *PostgresStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error { - if !store.configured { - return fmt.Errorf("store not configured") - } - - // Start transaction - tx, err := store.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %v", err) - } - defer tx.Rollback() - - // Clear existing data - if _, err := tx.ExecContext(ctx, "DELETE FROM credentials"); err != nil { - return fmt.Errorf("failed to clear credentials: %v", err) - } - if _, err := tx.ExecContext(ctx, "DELETE FROM users"); err != nil { - return fmt.Errorf("failed to clear users: %v", err) - } - - // Insert all identities - for _, identity := range config.Identities { - // Marshal account data - var accountDataJSON []byte - if identity.Account != nil { - accountDataJSON, err = json.Marshal(identity.Account) - if err != nil { - return fmt.Errorf("failed to marshal account data for user %s: %v", identity.Name, err) - } - } - - // Marshal actions - var actionsJSON []byte - if identity.Actions != nil { - actionsJSON, err = json.Marshal(identity.Actions) - if err != nil { - return fmt.Errorf("failed to marshal actions for user %s: %v", identity.Name, err) - } - } - - // Insert user - _, err := tx.ExecContext(ctx, - "INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)", - identity.Name, "", accountDataJSON, actionsJSON) - if err != nil { - return fmt.Errorf("failed to insert user %s: %v", identity.Name, err) - } - - // Insert credentials - for _, cred := range identity.Credentials { - _, err := tx.ExecContext(ctx, - "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)", - identity.Name, cred.AccessKey, cred.SecretKey) - if err != nil { - return fmt.Errorf("failed to insert credential for user %s: %v", identity.Name, err) - } - } - } - - return tx.Commit() -} - -func (store *PostgresStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error { - if !store.configured { - return fmt.Errorf("store not configured") - } - - // Check if user already exists - var count int - err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", identity.Name).Scan(&count) - if err != nil { - return fmt.Errorf("failed to check user existence: %v", err) - } - if count > 0 { - return credential.ErrUserAlreadyExists - } - - // Start transaction - tx, err := store.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %v", err) - } - defer tx.Rollback() - - // Marshal account data - var accountDataJSON []byte - if identity.Account != nil { - accountDataJSON, err = json.Marshal(identity.Account) - if err != nil { - return fmt.Errorf("failed to marshal account data: %v", err) - } - } - - // Marshal actions - var actionsJSON []byte - if identity.Actions != nil { - actionsJSON, err = json.Marshal(identity.Actions) - if err != nil { - return fmt.Errorf("failed to marshal actions: %v", err) - } - } - - // Insert user - _, err = tx.ExecContext(ctx, - "INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)", - identity.Name, "", accountDataJSON, actionsJSON) - if err != nil { - return fmt.Errorf("failed to insert user: %v", err) - } - - // Insert credentials - for _, cred := range identity.Credentials { - _, err = tx.ExecContext(ctx, - "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)", - identity.Name, cred.AccessKey, cred.SecretKey) - if err != nil { - return fmt.Errorf("failed to insert credential: %v", err) - } - } - - return tx.Commit() -} - -func (store *PostgresStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) { - if !store.configured { - return nil, fmt.Errorf("store not configured") - } - - var email string - var accountDataJSON, actionsJSON []byte - - err := store.db.QueryRowContext(ctx, - "SELECT email, account_data, actions FROM users WHERE username = $1", - username).Scan(&email, &accountDataJSON, &actionsJSON) - if err != nil { - if err == sql.ErrNoRows { - return nil, credential.ErrUserNotFound - } - return nil, fmt.Errorf("failed to query user: %v", err) - } - - identity := &iam_pb.Identity{ - Name: username, - } - - // Parse account data - if len(accountDataJSON) > 0 { - if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil { - return nil, fmt.Errorf("failed to unmarshal account data: %v", err) - } - } - - // Parse actions - if len(actionsJSON) > 0 { - if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil { - return nil, fmt.Errorf("failed to unmarshal actions: %v", err) - } - } - - // Query credentials - rows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username) - if err != nil { - return nil, fmt.Errorf("failed to query credentials: %v", err) - } - defer rows.Close() - - for rows.Next() { - var accessKey, secretKey string - if err := rows.Scan(&accessKey, &secretKey); err != nil { - return nil, fmt.Errorf("failed to scan credential: %v", err) - } - - identity.Credentials = append(identity.Credentials, &iam_pb.Credential{ - AccessKey: accessKey, - SecretKey: secretKey, - }) - } - - return identity, nil -} - -func (store *PostgresStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error { - if !store.configured { - return fmt.Errorf("store not configured") - } - - // Start transaction - tx, err := store.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %v", err) - } - defer tx.Rollback() - - // Check if user exists - var count int - err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count) - if err != nil { - return fmt.Errorf("failed to check user existence: %v", err) - } - if count == 0 { - return credential.ErrUserNotFound - } - - // Marshal account data - var accountDataJSON []byte - if identity.Account != nil { - accountDataJSON, err = json.Marshal(identity.Account) - if err != nil { - return fmt.Errorf("failed to marshal account data: %v", err) - } - } - - // Marshal actions - var actionsJSON []byte - if identity.Actions != nil { - actionsJSON, err = json.Marshal(identity.Actions) - if err != nil { - return fmt.Errorf("failed to marshal actions: %v", err) - } - } - - // Update user - _, err = tx.ExecContext(ctx, - "UPDATE users SET email = $2, account_data = $3, actions = $4, updated_at = CURRENT_TIMESTAMP WHERE username = $1", - username, "", accountDataJSON, actionsJSON) - if err != nil { - return fmt.Errorf("failed to update user: %v", err) - } - - // Delete existing credentials - _, err = tx.ExecContext(ctx, "DELETE FROM credentials WHERE username = $1", username) - if err != nil { - return fmt.Errorf("failed to delete existing credentials: %v", err) - } - - // Insert new credentials - for _, cred := range identity.Credentials { - _, err = tx.ExecContext(ctx, - "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)", - username, cred.AccessKey, cred.SecretKey) - if err != nil { - return fmt.Errorf("failed to insert credential: %v", err) - } - } - - return tx.Commit() -} - -func (store *PostgresStore) DeleteUser(ctx context.Context, username string) error { - if !store.configured { - return fmt.Errorf("store not configured") - } - - result, err := store.db.ExecContext(ctx, "DELETE FROM users WHERE username = $1", username) - if err != nil { - return fmt.Errorf("failed to delete user: %v", err) - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to get rows affected: %v", err) - } - - if rowsAffected == 0 { - return credential.ErrUserNotFound - } - - return nil -} - -func (store *PostgresStore) ListUsers(ctx context.Context) ([]string, error) { - if !store.configured { - return nil, fmt.Errorf("store not configured") - } - - rows, err := store.db.QueryContext(ctx, "SELECT username FROM users ORDER BY username") - if err != nil { - return nil, fmt.Errorf("failed to query users: %v", err) - } - defer rows.Close() - - var usernames []string - for rows.Next() { - var username string - if err := rows.Scan(&username); err != nil { - return nil, fmt.Errorf("failed to scan username: %v", err) - } - usernames = append(usernames, username) - } - - return usernames, nil -} - -func (store *PostgresStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) { - if !store.configured { - return nil, fmt.Errorf("store not configured") - } - - var username string - err := store.db.QueryRowContext(ctx, "SELECT username FROM credentials WHERE access_key = $1", accessKey).Scan(&username) - if err != nil { - if err == sql.ErrNoRows { - return nil, credential.ErrAccessKeyNotFound - } - return nil, fmt.Errorf("failed to query access key: %v", err) - } - - return store.GetUser(ctx, username) -} - -func (store *PostgresStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error { - if !store.configured { - return fmt.Errorf("store not configured") - } - - // Check if user exists - var count int - err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count) - if err != nil { - return fmt.Errorf("failed to check user existence: %v", err) - } - if count == 0 { - return credential.ErrUserNotFound - } - - // Insert credential - _, err = store.db.ExecContext(ctx, - "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)", - username, cred.AccessKey, cred.SecretKey) - if err != nil { - return fmt.Errorf("failed to insert credential: %v", err) - } - - return nil -} - -func (store *PostgresStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error { - if !store.configured { - return fmt.Errorf("store not configured") - } - - result, err := store.db.ExecContext(ctx, - "DELETE FROM credentials WHERE username = $1 AND access_key = $2", - username, accessKey) - if err != nil { - return fmt.Errorf("failed to delete access key: %v", err) - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to get rows affected: %v", err) - } - - if rowsAffected == 0 { - // Check if user exists - var count int - err = store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count) - if err != nil { - return fmt.Errorf("failed to check user existence: %v", err) - } - if count == 0 { - return credential.ErrUserNotFound - } - return credential.ErrAccessKeyNotFound + if _, err := store.db.Exec(policiesTable); err != nil { + return fmt.Errorf("failed to create policies table: %v", err) } return nil diff --git a/weed/credential/test/policy_test.go b/weed/credential/test/policy_test.go new file mode 100644 index 000000000..341a05003 --- /dev/null +++ b/weed/credential/test/policy_test.go @@ -0,0 +1,146 @@ +package test + +import ( + "context" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/credential" + "github.com/seaweedfs/seaweedfs/weed/credential/memory" + + // Import all store implementations to register them + _ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc" + _ "github.com/seaweedfs/seaweedfs/weed/credential/memory" + _ "github.com/seaweedfs/seaweedfs/weed/credential/postgres" +) + +// TestPolicyManagement tests policy management across all credential stores +func TestPolicyManagement(t *testing.T) { + ctx := context.Background() + + // Test with memory store (easiest to test) + credentialManager, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "") + if err != nil { + t.Fatalf("Failed to create credential manager: %v", err) + } + + // Test policy operations + testPolicyOperations(t, ctx, credentialManager) +} + +func testPolicyOperations(t *testing.T, ctx context.Context, credentialManager *credential.CredentialManager) { + store := credentialManager.GetStore() + + // Cast to memory store to access policy methods + memoryStore, ok := store.(*memory.MemoryStore) + if !ok { + t.Skip("Store is not a memory store") + } + + // Test GetPolicies (should be empty initially) + policies, err := memoryStore.GetPolicies(ctx) + if err != nil { + t.Fatalf("Failed to get policies: %v", err) + } + if len(policies) != 0 { + t.Errorf("Expected 0 policies, got %d", len(policies)) + } + + // Test CreatePolicy + testPolicy := credential.PolicyDocument{ + Version: "2012-10-17", + Statement: []*credential.PolicyStatement{ + { + Effect: "Allow", + Action: []string{"s3:GetObject"}, + Resource: []string{"arn:aws:s3:::test-bucket/*"}, + }, + }, + } + + err = memoryStore.CreatePolicy(ctx, "test-policy", testPolicy) + if err != nil { + t.Fatalf("Failed to create policy: %v", err) + } + + // Test GetPolicies (should have 1 policy now) + policies, err = memoryStore.GetPolicies(ctx) + if err != nil { + t.Fatalf("Failed to get policies: %v", err) + } + if len(policies) != 1 { + t.Errorf("Expected 1 policy, got %d", len(policies)) + } + + // Verify policy content + policy, exists := policies["test-policy"] + if !exists { + t.Error("test-policy not found") + } + if policy.Version != "2012-10-17" { + t.Errorf("Expected policy version '2012-10-17', got '%s'", policy.Version) + } + if len(policy.Statement) != 1 { + t.Errorf("Expected 1 statement, got %d", len(policy.Statement)) + } + + // Test UpdatePolicy + updatedPolicy := credential.PolicyDocument{ + Version: "2012-10-17", + Statement: []*credential.PolicyStatement{ + { + Effect: "Allow", + Action: []string{"s3:GetObject", "s3:PutObject"}, + Resource: []string{"arn:aws:s3:::test-bucket/*"}, + }, + }, + } + + err = memoryStore.UpdatePolicy(ctx, "test-policy", updatedPolicy) + if err != nil { + t.Fatalf("Failed to update policy: %v", err) + } + + // Verify the update + policies, err = memoryStore.GetPolicies(ctx) + if err != nil { + t.Fatalf("Failed to get policies after update: %v", err) + } + + updatedPolicyResult, exists := policies["test-policy"] + if !exists { + t.Error("test-policy not found after update") + } + if len(updatedPolicyResult.Statement) != 1 { + t.Errorf("Expected 1 statement after update, got %d", len(updatedPolicyResult.Statement)) + } + if len(updatedPolicyResult.Statement[0].Action) != 2 { + t.Errorf("Expected 2 actions after update, got %d", len(updatedPolicyResult.Statement[0].Action)) + } + + // Test DeletePolicy + err = memoryStore.DeletePolicy(ctx, "test-policy") + if err != nil { + t.Fatalf("Failed to delete policy: %v", err) + } + + // Verify deletion + policies, err = memoryStore.GetPolicies(ctx) + if err != nil { + t.Fatalf("Failed to get policies after deletion: %v", err) + } + if len(policies) != 0 { + t.Errorf("Expected 0 policies after deletion, got %d", len(policies)) + } +} + +// TestPolicyManagementWithFilerEtc tests policy management with filer_etc store +func TestPolicyManagementWithFilerEtc(t *testing.T) { + // Skip this test if we can't connect to a filer + t.Skip("Filer connection required for filer_etc store testing") +} + +// TestPolicyManagementWithPostgres tests policy management with postgres store +func TestPolicyManagementWithPostgres(t *testing.T) { + // Skip this test if we can't connect to PostgreSQL + t.Skip("PostgreSQL connection required for postgres store testing") +} diff --git a/weed/worker/tasks/balance/ui_templ.go b/weed/worker/tasks/balance/ui_templ.go deleted file mode 100644 index 54998af4c..000000000 --- a/weed/worker/tasks/balance/ui_templ.go +++ /dev/null @@ -1,369 +0,0 @@ -package balance - -import ( - "fmt" - "strconv" - "time" - - "github.com/seaweedfs/seaweedfs/weed/admin/view/components" - "github.com/seaweedfs/seaweedfs/weed/glog" - "github.com/seaweedfs/seaweedfs/weed/worker/types" -) - -// Helper function to format seconds as duration string -func formatDurationFromSeconds(seconds int) string { - d := time.Duration(seconds) * time.Second - return d.String() -} - -// Helper functions to convert between seconds and value+unit format -func secondsToValueAndUnit(seconds int) (float64, string) { - if seconds == 0 { - return 0, "minutes" - } - - // Try days first - if seconds%(24*3600) == 0 && seconds >= 24*3600 { - return float64(seconds / (24 * 3600)), "days" - } - - // Try hours - if seconds%3600 == 0 && seconds >= 3600 { - return float64(seconds / 3600), "hours" - } - - // Default to minutes - return float64(seconds / 60), "minutes" -} - -func valueAndUnitToSeconds(value float64, unit string) int { - switch unit { - case "days": - return int(value * 24 * 3600) - case "hours": - return int(value * 3600) - case "minutes": - return int(value * 60) - default: - return int(value * 60) // Default to minutes - } -} - -// UITemplProvider provides the templ-based UI for balance task configuration -type UITemplProvider struct { - detector *BalanceDetector - scheduler *BalanceScheduler -} - -// NewUITemplProvider creates a new balance templ UI provider -func NewUITemplProvider(detector *BalanceDetector, scheduler *BalanceScheduler) *UITemplProvider { - return &UITemplProvider{ - detector: detector, - scheduler: scheduler, - } -} - -// GetTaskType returns the task type -func (ui *UITemplProvider) GetTaskType() types.TaskType { - return types.TaskTypeBalance -} - -// GetDisplayName returns the human-readable name -func (ui *UITemplProvider) GetDisplayName() string { - return "Volume Balance" -} - -// GetDescription returns a description of what this task does -func (ui *UITemplProvider) GetDescription() string { - return "Redistributes volumes across volume servers to optimize storage utilization and performance" -} - -// GetIcon returns the icon CSS class for this task type -func (ui *UITemplProvider) GetIcon() string { - return "fas fa-balance-scale text-secondary" -} - -// RenderConfigSections renders the configuration as templ section data -func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) { - config := ui.getCurrentBalanceConfig() - - // Detection settings section - detectionSection := components.ConfigSectionData{ - Title: "Detection Settings", - Icon: "fas fa-search", - Description: "Configure when balance tasks should be triggered", - Fields: []interface{}{ - components.CheckboxFieldData{ - FormFieldData: components.FormFieldData{ - Name: "enabled", - Label: "Enable Balance Tasks", - Description: "Whether balance tasks should be automatically created", - }, - Checked: config.Enabled, - }, - components.NumberFieldData{ - FormFieldData: components.FormFieldData{ - Name: "imbalance_threshold", - Label: "Imbalance Threshold", - Description: "Trigger balance when storage imbalance exceeds this percentage (0.0-1.0)", - Required: true, - }, - Value: config.ImbalanceThreshold, - Step: "0.01", - Min: floatPtr(0.0), - Max: floatPtr(1.0), - }, - components.DurationInputFieldData{ - FormFieldData: components.FormFieldData{ - Name: "scan_interval", - Label: "Scan Interval", - Description: "How often to scan for imbalanced volumes", - Required: true, - }, - Seconds: config.ScanIntervalSeconds, - }, - }, - } - - // Scheduling settings section - schedulingSection := components.ConfigSectionData{ - Title: "Scheduling Settings", - Icon: "fas fa-clock", - Description: "Configure task scheduling and concurrency", - Fields: []interface{}{ - components.NumberFieldData{ - FormFieldData: components.FormFieldData{ - Name: "max_concurrent", - Label: "Max Concurrent Tasks", - Description: "Maximum number of balance tasks that can run simultaneously", - Required: true, - }, - Value: float64(config.MaxConcurrent), - Step: "1", - Min: floatPtr(1), - }, - components.NumberFieldData{ - FormFieldData: components.FormFieldData{ - Name: "min_server_count", - Label: "Minimum Server Count", - Description: "Only balance when at least this many servers are available", - Required: true, - }, - Value: float64(config.MinServerCount), - Step: "1", - Min: floatPtr(1), - }, - }, - } - - // Timing constraints section - timingSection := components.ConfigSectionData{ - Title: "Timing Constraints", - Icon: "fas fa-calendar-clock", - Description: "Configure when balance operations are allowed", - Fields: []interface{}{ - components.CheckboxFieldData{ - FormFieldData: components.FormFieldData{ - Name: "move_during_off_hours", - Label: "Restrict to Off-Hours", - Description: "Only perform balance operations during off-peak hours", - }, - Checked: config.MoveDuringOffHours, - }, - components.TextFieldData{ - FormFieldData: components.FormFieldData{ - Name: "off_hours_start", - Label: "Off-Hours Start Time", - Description: "Start time for off-hours window (e.g., 23:00)", - }, - Value: config.OffHoursStart, - }, - components.TextFieldData{ - FormFieldData: components.FormFieldData{ - Name: "off_hours_end", - Label: "Off-Hours End Time", - Description: "End time for off-hours window (e.g., 06:00)", - }, - Value: config.OffHoursEnd, - }, - }, - } - - // Performance impact info section - performanceSection := components.ConfigSectionData{ - Title: "Performance Considerations", - Icon: "fas fa-exclamation-triangle", - Description: "Important information about balance operations", - Fields: []interface{}{ - components.TextFieldData{ - FormFieldData: components.FormFieldData{ - Name: "performance_info", - Label: "Performance Impact", - Description: "Volume balancing involves data movement and can impact cluster performance", - }, - Value: "Enable off-hours restriction to minimize impact on production workloads", - }, - components.TextFieldData{ - FormFieldData: components.FormFieldData{ - Name: "safety_info", - Label: "Safety Requirements", - Description: fmt.Sprintf("Requires at least %d servers to ensure data safety during moves", config.MinServerCount), - }, - Value: "Maintains data safety during volume moves between servers", - }, - }, - } - - return []components.ConfigSectionData{detectionSection, schedulingSection, timingSection, performanceSection}, nil -} - -// ParseConfigForm parses form data into configuration -func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) { - config := &BalanceConfig{} - - // Parse enabled checkbox - config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on" - - // Parse imbalance threshold - if thresholdStr := formData["imbalance_threshold"]; len(thresholdStr) > 0 { - if threshold, err := strconv.ParseFloat(thresholdStr[0], 64); err != nil { - return nil, fmt.Errorf("invalid imbalance threshold: %v", err) - } else if threshold < 0 || threshold > 1 { - return nil, fmt.Errorf("imbalance threshold must be between 0.0 and 1.0") - } else { - config.ImbalanceThreshold = threshold - } - } - - // Parse scan interval - if valueStr := formData["scan_interval"]; len(valueStr) > 0 { - if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil { - return nil, fmt.Errorf("invalid scan interval value: %v", err) - } else { - unit := "minutes" // default - if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 { - unit = unitStr[0] - } - config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit) - } - } - - // Parse max concurrent - if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 { - if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil { - return nil, fmt.Errorf("invalid max concurrent: %v", err) - } else if concurrent < 1 { - return nil, fmt.Errorf("max concurrent must be at least 1") - } else { - config.MaxConcurrent = concurrent - } - } - - // Parse min server count - if serverCountStr := formData["min_server_count"]; len(serverCountStr) > 0 { - if serverCount, err := strconv.Atoi(serverCountStr[0]); err != nil { - return nil, fmt.Errorf("invalid min server count: %v", err) - } else if serverCount < 1 { - return nil, fmt.Errorf("min server count must be at least 1") - } else { - config.MinServerCount = serverCount - } - } - - // Parse move during off hours - config.MoveDuringOffHours = len(formData["move_during_off_hours"]) > 0 && formData["move_during_off_hours"][0] == "on" - - // Parse off hours start time - if startStr := formData["off_hours_start"]; len(startStr) > 0 { - config.OffHoursStart = startStr[0] - } - - // Parse off hours end time - if endStr := formData["off_hours_end"]; len(endStr) > 0 { - config.OffHoursEnd = endStr[0] - } - - return config, nil -} - -// GetCurrentConfig returns the current configuration -func (ui *UITemplProvider) GetCurrentConfig() interface{} { - return ui.getCurrentBalanceConfig() -} - -// ApplyConfig applies the new configuration -func (ui *UITemplProvider) ApplyConfig(config interface{}) error { - balanceConfig, ok := config.(*BalanceConfig) - if !ok { - return fmt.Errorf("invalid config type, expected *BalanceConfig") - } - - // Apply to detector - if ui.detector != nil { - ui.detector.SetEnabled(balanceConfig.Enabled) - ui.detector.SetThreshold(balanceConfig.ImbalanceThreshold) - ui.detector.SetMinCheckInterval(time.Duration(balanceConfig.ScanIntervalSeconds) * time.Second) - } - - // Apply to scheduler - if ui.scheduler != nil { - ui.scheduler.SetEnabled(balanceConfig.Enabled) - ui.scheduler.SetMaxConcurrent(balanceConfig.MaxConcurrent) - ui.scheduler.SetMinServerCount(balanceConfig.MinServerCount) - ui.scheduler.SetMoveDuringOffHours(balanceConfig.MoveDuringOffHours) - ui.scheduler.SetOffHoursStart(balanceConfig.OffHoursStart) - ui.scheduler.SetOffHoursEnd(balanceConfig.OffHoursEnd) - } - - glog.V(1).Infof("Applied balance configuration: enabled=%v, threshold=%.1f%%, max_concurrent=%d, min_servers=%d, off_hours=%v", - balanceConfig.Enabled, balanceConfig.ImbalanceThreshold*100, balanceConfig.MaxConcurrent, - balanceConfig.MinServerCount, balanceConfig.MoveDuringOffHours) - - return nil -} - -// getCurrentBalanceConfig gets the current configuration from detector and scheduler -func (ui *UITemplProvider) getCurrentBalanceConfig() *BalanceConfig { - config := &BalanceConfig{ - // Default values (fallback if detectors/schedulers are nil) - Enabled: true, - ImbalanceThreshold: 0.1, // 10% imbalance - ScanIntervalSeconds: int((4 * time.Hour).Seconds()), - MaxConcurrent: 1, - MinServerCount: 3, - MoveDuringOffHours: true, - OffHoursStart: "23:00", - OffHoursEnd: "06:00", - } - - // Get current values from detector - if ui.detector != nil { - config.Enabled = ui.detector.IsEnabled() - config.ImbalanceThreshold = ui.detector.GetThreshold() - config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds()) - } - - // Get current values from scheduler - if ui.scheduler != nil { - config.MaxConcurrent = ui.scheduler.GetMaxConcurrent() - config.MinServerCount = ui.scheduler.GetMinServerCount() - config.MoveDuringOffHours = ui.scheduler.GetMoveDuringOffHours() - config.OffHoursStart = ui.scheduler.GetOffHoursStart() - config.OffHoursEnd = ui.scheduler.GetOffHoursEnd() - } - - return config -} - -// floatPtr is a helper function to create float64 pointers -func floatPtr(f float64) *float64 { - return &f -} - -// RegisterUITempl registers the balance templ UI provider with the UI registry -func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *BalanceDetector, scheduler *BalanceScheduler) { - uiProvider := NewUITemplProvider(detector, scheduler) - uiRegistry.RegisterUI(uiProvider) - - glog.V(1).Infof("✅ Registered balance task templ UI provider") -} diff --git a/weed/worker/tasks/erasure_coding/ui_templ.go b/weed/worker/tasks/erasure_coding/ui_templ.go deleted file mode 100644 index 12c3d199e..000000000 --- a/weed/worker/tasks/erasure_coding/ui_templ.go +++ /dev/null @@ -1,319 +0,0 @@ -package erasure_coding - -import ( - "fmt" - "strconv" - "time" - - "github.com/seaweedfs/seaweedfs/weed/admin/view/components" - "github.com/seaweedfs/seaweedfs/weed/glog" - "github.com/seaweedfs/seaweedfs/weed/worker/types" -) - -// Helper function to format seconds as duration string -func formatDurationFromSeconds(seconds int) string { - d := time.Duration(seconds) * time.Second - return d.String() -} - -// Helper function to convert value and unit to seconds -func valueAndUnitToSeconds(value float64, unit string) int { - switch unit { - case "days": - return int(value * 24 * 60 * 60) - case "hours": - return int(value * 60 * 60) - case "minutes": - return int(value * 60) - default: - return int(value * 60) // Default to minutes - } -} - -// UITemplProvider provides the templ-based UI for erasure coding task configuration -type UITemplProvider struct { - detector *EcDetector - scheduler *Scheduler -} - -// NewUITemplProvider creates a new erasure coding templ UI provider -func NewUITemplProvider(detector *EcDetector, scheduler *Scheduler) *UITemplProvider { - return &UITemplProvider{ - detector: detector, - scheduler: scheduler, - } -} - -// ErasureCodingConfig is defined in ui.go - we reuse it - -// GetTaskType returns the task type -func (ui *UITemplProvider) GetTaskType() types.TaskType { - return types.TaskTypeErasureCoding -} - -// GetDisplayName returns the human-readable name -func (ui *UITemplProvider) GetDisplayName() string { - return "Erasure Coding" -} - -// GetDescription returns a description of what this task does -func (ui *UITemplProvider) GetDescription() string { - return "Converts replicated volumes to erasure-coded format for efficient storage" -} - -// GetIcon returns the icon CSS class for this task type -func (ui *UITemplProvider) GetIcon() string { - return "fas fa-shield-alt text-info" -} - -// RenderConfigSections renders the configuration as templ section data -func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) { - config := ui.getCurrentECConfig() - - // Detection settings section - detectionSection := components.ConfigSectionData{ - Title: "Detection Settings", - Icon: "fas fa-search", - Description: "Configure when erasure coding tasks should be triggered", - Fields: []interface{}{ - components.CheckboxFieldData{ - FormFieldData: components.FormFieldData{ - Name: "enabled", - Label: "Enable Erasure Coding Tasks", - Description: "Whether erasure coding tasks should be automatically created", - }, - Checked: config.Enabled, - }, - components.DurationInputFieldData{ - FormFieldData: components.FormFieldData{ - Name: "scan_interval", - Label: "Scan Interval", - Description: "How often to scan for volumes needing erasure coding", - Required: true, - }, - Seconds: config.ScanIntervalSeconds, - }, - components.DurationInputFieldData{ - FormFieldData: components.FormFieldData{ - Name: "volume_age_threshold", - Label: "Volume Age Threshold", - Description: "Only apply erasure coding to volumes older than this age", - Required: true, - }, - Seconds: config.VolumeAgeHoursSeconds, - }, - }, - } - - // Erasure coding parameters section - paramsSection := components.ConfigSectionData{ - Title: "Erasure Coding Parameters", - Icon: "fas fa-cogs", - Description: "Configure erasure coding scheme and performance", - Fields: []interface{}{ - components.NumberFieldData{ - FormFieldData: components.FormFieldData{ - Name: "data_shards", - Label: "Data Shards", - Description: "Number of data shards in the erasure coding scheme", - Required: true, - }, - Value: float64(config.ShardCount), - Step: "1", - Min: floatPtr(1), - Max: floatPtr(16), - }, - components.NumberFieldData{ - FormFieldData: components.FormFieldData{ - Name: "parity_shards", - Label: "Parity Shards", - Description: "Number of parity shards (determines fault tolerance)", - Required: true, - }, - Value: float64(config.ParityCount), - Step: "1", - Min: floatPtr(1), - Max: floatPtr(16), - }, - components.NumberFieldData{ - FormFieldData: components.FormFieldData{ - Name: "max_concurrent", - Label: "Max Concurrent Tasks", - Description: "Maximum number of erasure coding tasks that can run simultaneously", - Required: true, - }, - Value: float64(config.MaxConcurrent), - Step: "1", - Min: floatPtr(1), - }, - }, - } - - // Performance impact info section - infoSection := components.ConfigSectionData{ - Title: "Performance Impact", - Icon: "fas fa-info-circle", - Description: "Important information about erasure coding operations", - Fields: []interface{}{ - components.TextFieldData{ - FormFieldData: components.FormFieldData{ - Name: "durability_info", - Label: "Durability", - Description: fmt.Sprintf("With %d+%d configuration, can tolerate up to %d shard failures", - config.ShardCount, config.ParityCount, config.ParityCount), - }, - Value: "High durability with space efficiency", - }, - components.TextFieldData{ - FormFieldData: components.FormFieldData{ - Name: "performance_info", - Label: "Performance Note", - Description: "Erasure coding is CPU and I/O intensive. Consider running during off-peak hours", - }, - Value: "Schedule during low-traffic periods", - }, - }, - } - - return []components.ConfigSectionData{detectionSection, paramsSection, infoSection}, nil -} - -// ParseConfigForm parses form data into configuration -func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) { - config := &ErasureCodingConfig{} - - // Parse enabled checkbox - config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on" - - // Parse volume age threshold - if valueStr := formData["volume_age_threshold"]; len(valueStr) > 0 { - if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil { - return nil, fmt.Errorf("invalid volume age threshold value: %v", err) - } else { - unit := "hours" // default - if unitStr := formData["volume_age_threshold_unit"]; len(unitStr) > 0 { - unit = unitStr[0] - } - config.VolumeAgeHoursSeconds = valueAndUnitToSeconds(value, unit) - } - } - - // Parse scan interval - if valueStr := formData["scan_interval"]; len(valueStr) > 0 { - if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil { - return nil, fmt.Errorf("invalid scan interval value: %v", err) - } else { - unit := "hours" // default - if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 { - unit = unitStr[0] - } - config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit) - } - } - - // Parse data shards - if shardsStr := formData["data_shards"]; len(shardsStr) > 0 { - if shards, err := strconv.Atoi(shardsStr[0]); err != nil { - return nil, fmt.Errorf("invalid data shards: %v", err) - } else if shards < 1 || shards > 16 { - return nil, fmt.Errorf("data shards must be between 1 and 16") - } else { - config.ShardCount = shards - } - } - - // Parse parity shards - if shardsStr := formData["parity_shards"]; len(shardsStr) > 0 { - if shards, err := strconv.Atoi(shardsStr[0]); err != nil { - return nil, fmt.Errorf("invalid parity shards: %v", err) - } else if shards < 1 || shards > 16 { - return nil, fmt.Errorf("parity shards must be between 1 and 16") - } else { - config.ParityCount = shards - } - } - - // Parse max concurrent - if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 { - if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil { - return nil, fmt.Errorf("invalid max concurrent: %v", err) - } else if concurrent < 1 { - return nil, fmt.Errorf("max concurrent must be at least 1") - } else { - config.MaxConcurrent = concurrent - } - } - - return config, nil -} - -// GetCurrentConfig returns the current configuration -func (ui *UITemplProvider) GetCurrentConfig() interface{} { - return ui.getCurrentECConfig() -} - -// ApplyConfig applies the new configuration -func (ui *UITemplProvider) ApplyConfig(config interface{}) error { - ecConfig, ok := config.(*ErasureCodingConfig) - if !ok { - return fmt.Errorf("invalid config type, expected *ErasureCodingConfig") - } - - // Apply to detector - if ui.detector != nil { - ui.detector.SetEnabled(ecConfig.Enabled) - ui.detector.SetVolumeAgeHours(ecConfig.VolumeAgeHoursSeconds) - ui.detector.SetScanInterval(time.Duration(ecConfig.ScanIntervalSeconds) * time.Second) - } - - // Apply to scheduler - if ui.scheduler != nil { - ui.scheduler.SetMaxConcurrent(ecConfig.MaxConcurrent) - ui.scheduler.SetEnabled(ecConfig.Enabled) - } - - glog.V(1).Infof("Applied erasure coding configuration: enabled=%v, age_threshold=%ds, max_concurrent=%d", - ecConfig.Enabled, ecConfig.VolumeAgeHoursSeconds, ecConfig.MaxConcurrent) - - return nil -} - -// getCurrentECConfig gets the current configuration from detector and scheduler -func (ui *UITemplProvider) getCurrentECConfig() *ErasureCodingConfig { - config := &ErasureCodingConfig{ - // Default values (fallback if detectors/schedulers are nil) - Enabled: true, - VolumeAgeHoursSeconds: int((24 * time.Hour).Seconds()), - ScanIntervalSeconds: int((2 * time.Hour).Seconds()), - MaxConcurrent: 1, - ShardCount: 10, - ParityCount: 4, - } - - // Get current values from detector - if ui.detector != nil { - config.Enabled = ui.detector.IsEnabled() - config.VolumeAgeHoursSeconds = ui.detector.GetVolumeAgeHours() - config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds()) - } - - // Get current values from scheduler - if ui.scheduler != nil { - config.MaxConcurrent = ui.scheduler.GetMaxConcurrent() - } - - return config -} - -// floatPtr is a helper function to create float64 pointers -func floatPtr(f float64) *float64 { - return &f -} - -// RegisterUITempl registers the erasure coding templ UI provider with the UI registry -func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *EcDetector, scheduler *Scheduler) { - uiProvider := NewUITemplProvider(detector, scheduler) - uiRegistry.RegisterUI(uiProvider) - - glog.V(1).Infof("✅ Registered erasure coding task templ UI provider") -} diff --git a/weed/worker/tasks/vacuum/ui_templ.go b/weed/worker/tasks/vacuum/ui_templ.go deleted file mode 100644 index 15558b832..000000000 --- a/weed/worker/tasks/vacuum/ui_templ.go +++ /dev/null @@ -1,330 +0,0 @@ -package vacuum - -import ( - "fmt" - "strconv" - "time" - - "github.com/seaweedfs/seaweedfs/weed/admin/view/components" - "github.com/seaweedfs/seaweedfs/weed/glog" - "github.com/seaweedfs/seaweedfs/weed/worker/types" -) - -// Helper function to format seconds as duration string -func formatDurationFromSeconds(seconds int) string { - d := time.Duration(seconds) * time.Second - return d.String() -} - -// Helper functions to convert between seconds and value+unit format -func secondsToValueAndUnit(seconds int) (float64, string) { - if seconds == 0 { - return 0, "minutes" - } - - // Try days first - if seconds%(24*3600) == 0 && seconds >= 24*3600 { - return float64(seconds / (24 * 3600)), "days" - } - - // Try hours - if seconds%3600 == 0 && seconds >= 3600 { - return float64(seconds / 3600), "hours" - } - - // Default to minutes - return float64(seconds / 60), "minutes" -} - -func valueAndUnitToSeconds(value float64, unit string) int { - switch unit { - case "days": - return int(value * 24 * 3600) - case "hours": - return int(value * 3600) - case "minutes": - return int(value * 60) - default: - return int(value * 60) // Default to minutes - } -} - -// UITemplProvider provides the templ-based UI for vacuum task configuration -type UITemplProvider struct { - detector *VacuumDetector - scheduler *VacuumScheduler -} - -// NewUITemplProvider creates a new vacuum templ UI provider -func NewUITemplProvider(detector *VacuumDetector, scheduler *VacuumScheduler) *UITemplProvider { - return &UITemplProvider{ - detector: detector, - scheduler: scheduler, - } -} - -// GetTaskType returns the task type -func (ui *UITemplProvider) GetTaskType() types.TaskType { - return types.TaskTypeVacuum -} - -// GetDisplayName returns the human-readable name -func (ui *UITemplProvider) GetDisplayName() string { - return "Volume Vacuum" -} - -// GetDescription returns a description of what this task does -func (ui *UITemplProvider) GetDescription() string { - return "Reclaims disk space by removing deleted files from volumes" -} - -// GetIcon returns the icon CSS class for this task type -func (ui *UITemplProvider) GetIcon() string { - return "fas fa-broom text-primary" -} - -// RenderConfigSections renders the configuration as templ section data -func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) { - config := ui.getCurrentVacuumConfig() - - // Detection settings section - detectionSection := components.ConfigSectionData{ - Title: "Detection Settings", - Icon: "fas fa-search", - Description: "Configure when vacuum tasks should be triggered", - Fields: []interface{}{ - components.CheckboxFieldData{ - FormFieldData: components.FormFieldData{ - Name: "enabled", - Label: "Enable Vacuum Tasks", - Description: "Whether vacuum tasks should be automatically created", - }, - Checked: config.Enabled, - }, - components.NumberFieldData{ - FormFieldData: components.FormFieldData{ - Name: "garbage_threshold", - Label: "Garbage Threshold", - Description: "Trigger vacuum when garbage ratio exceeds this percentage (0.0-1.0)", - Required: true, - }, - Value: config.GarbageThreshold, - Step: "0.01", - Min: floatPtr(0.0), - Max: floatPtr(1.0), - }, - components.DurationInputFieldData{ - FormFieldData: components.FormFieldData{ - Name: "scan_interval", - Label: "Scan Interval", - Description: "How often to scan for volumes needing vacuum", - Required: true, - }, - Seconds: config.ScanIntervalSeconds, - }, - components.DurationInputFieldData{ - FormFieldData: components.FormFieldData{ - Name: "min_volume_age", - Label: "Minimum Volume Age", - Description: "Only vacuum volumes older than this duration", - Required: true, - }, - Seconds: config.MinVolumeAgeSeconds, - }, - }, - } - - // Scheduling settings section - schedulingSection := components.ConfigSectionData{ - Title: "Scheduling Settings", - Icon: "fas fa-clock", - Description: "Configure task scheduling and concurrency", - Fields: []interface{}{ - components.NumberFieldData{ - FormFieldData: components.FormFieldData{ - Name: "max_concurrent", - Label: "Max Concurrent Tasks", - Description: "Maximum number of vacuum tasks that can run simultaneously", - Required: true, - }, - Value: float64(config.MaxConcurrent), - Step: "1", - Min: floatPtr(1), - }, - components.DurationInputFieldData{ - FormFieldData: components.FormFieldData{ - Name: "min_interval", - Label: "Minimum Interval", - Description: "Minimum time between vacuum operations on the same volume", - Required: true, - }, - Seconds: config.MinIntervalSeconds, - }, - }, - } - - // Performance impact info section - performanceSection := components.ConfigSectionData{ - Title: "Performance Impact", - Icon: "fas fa-exclamation-triangle", - Description: "Important information about vacuum operations", - Fields: []interface{}{ - components.TextFieldData{ - FormFieldData: components.FormFieldData{ - Name: "info_impact", - Label: "Impact", - Description: "Volume vacuum operations are I/O intensive and should be scheduled appropriately", - }, - Value: "Configure thresholds and intervals based on your storage usage patterns", - }, - }, - } - - return []components.ConfigSectionData{detectionSection, schedulingSection, performanceSection}, nil -} - -// ParseConfigForm parses form data into configuration -func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) { - config := &VacuumConfig{} - - // Parse enabled checkbox - config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on" - - // Parse garbage threshold - if thresholdStr := formData["garbage_threshold"]; len(thresholdStr) > 0 { - if threshold, err := strconv.ParseFloat(thresholdStr[0], 64); err != nil { - return nil, fmt.Errorf("invalid garbage threshold: %v", err) - } else if threshold < 0 || threshold > 1 { - return nil, fmt.Errorf("garbage threshold must be between 0.0 and 1.0") - } else { - config.GarbageThreshold = threshold - } - } - - // Parse scan interval - if valueStr := formData["scan_interval"]; len(valueStr) > 0 { - if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil { - return nil, fmt.Errorf("invalid scan interval value: %v", err) - } else { - unit := "minutes" // default - if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 { - unit = unitStr[0] - } - config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit) - } - } - - // Parse min volume age - if valueStr := formData["min_volume_age"]; len(valueStr) > 0 { - if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil { - return nil, fmt.Errorf("invalid min volume age value: %v", err) - } else { - unit := "minutes" // default - if unitStr := formData["min_volume_age_unit"]; len(unitStr) > 0 { - unit = unitStr[0] - } - config.MinVolumeAgeSeconds = valueAndUnitToSeconds(value, unit) - } - } - - // Parse max concurrent - if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 { - if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil { - return nil, fmt.Errorf("invalid max concurrent: %v", err) - } else if concurrent < 1 { - return nil, fmt.Errorf("max concurrent must be at least 1") - } else { - config.MaxConcurrent = concurrent - } - } - - // Parse min interval - if valueStr := formData["min_interval"]; len(valueStr) > 0 { - if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil { - return nil, fmt.Errorf("invalid min interval value: %v", err) - } else { - unit := "minutes" // default - if unitStr := formData["min_interval_unit"]; len(unitStr) > 0 { - unit = unitStr[0] - } - config.MinIntervalSeconds = valueAndUnitToSeconds(value, unit) - } - } - - return config, nil -} - -// GetCurrentConfig returns the current configuration -func (ui *UITemplProvider) GetCurrentConfig() interface{} { - return ui.getCurrentVacuumConfig() -} - -// ApplyConfig applies the new configuration -func (ui *UITemplProvider) ApplyConfig(config interface{}) error { - vacuumConfig, ok := config.(*VacuumConfig) - if !ok { - return fmt.Errorf("invalid config type, expected *VacuumConfig") - } - - // Apply to detector - if ui.detector != nil { - ui.detector.SetEnabled(vacuumConfig.Enabled) - ui.detector.SetGarbageThreshold(vacuumConfig.GarbageThreshold) - ui.detector.SetScanInterval(time.Duration(vacuumConfig.ScanIntervalSeconds) * time.Second) - ui.detector.SetMinVolumeAge(time.Duration(vacuumConfig.MinVolumeAgeSeconds) * time.Second) - } - - // Apply to scheduler - if ui.scheduler != nil { - ui.scheduler.SetEnabled(vacuumConfig.Enabled) - ui.scheduler.SetMaxConcurrent(vacuumConfig.MaxConcurrent) - ui.scheduler.SetMinInterval(time.Duration(vacuumConfig.MinIntervalSeconds) * time.Second) - } - - glog.V(1).Infof("Applied vacuum configuration: enabled=%v, threshold=%.1f%%, scan_interval=%s, max_concurrent=%d", - vacuumConfig.Enabled, vacuumConfig.GarbageThreshold*100, formatDurationFromSeconds(vacuumConfig.ScanIntervalSeconds), vacuumConfig.MaxConcurrent) - - return nil -} - -// getCurrentVacuumConfig gets the current configuration from detector and scheduler -func (ui *UITemplProvider) getCurrentVacuumConfig() *VacuumConfig { - config := &VacuumConfig{ - // Default values (fallback if detectors/schedulers are nil) - Enabled: true, - GarbageThreshold: 0.3, - ScanIntervalSeconds: int((30 * time.Minute).Seconds()), - MinVolumeAgeSeconds: int((1 * time.Hour).Seconds()), - MaxConcurrent: 2, - MinIntervalSeconds: int((6 * time.Hour).Seconds()), - } - - // Get current values from detector - if ui.detector != nil { - config.Enabled = ui.detector.IsEnabled() - config.GarbageThreshold = ui.detector.GetGarbageThreshold() - config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds()) - config.MinVolumeAgeSeconds = int(ui.detector.GetMinVolumeAge().Seconds()) - } - - // Get current values from scheduler - if ui.scheduler != nil { - config.MaxConcurrent = ui.scheduler.GetMaxConcurrent() - config.MinIntervalSeconds = int(ui.scheduler.GetMinInterval().Seconds()) - } - - return config -} - -// floatPtr is a helper function to create float64 pointers -func floatPtr(f float64) *float64 { - return &f -} - -// RegisterUITempl registers the vacuum templ UI provider with the UI registry -func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *VacuumDetector, scheduler *VacuumScheduler) { - uiProvider := NewUITemplProvider(detector, scheduler) - uiRegistry.RegisterUI(uiProvider) - - glog.V(1).Infof("✅ Registered vacuum task templ UI provider") -} diff --git a/weed/worker/types/task_ui_templ.go b/weed/worker/types/task_ui_templ.go deleted file mode 100644 index 77e80b408..000000000 --- a/weed/worker/types/task_ui_templ.go +++ /dev/null @@ -1,63 +0,0 @@ -package types - -import ( - "github.com/seaweedfs/seaweedfs/weed/admin/view/components" -) - -// TaskUITemplProvider defines how tasks provide their configuration UI using templ components -type TaskUITemplProvider interface { - // GetTaskType returns the task type - GetTaskType() TaskType - - // GetDisplayName returns the human-readable name - GetDisplayName() string - - // GetDescription returns a description of what this task does - GetDescription() string - - // GetIcon returns the icon CSS class or HTML for this task type - GetIcon() string - - // RenderConfigSections renders the configuration as templ section data - RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) - - // ParseConfigForm parses form data into configuration - ParseConfigForm(formData map[string][]string) (interface{}, error) - - // GetCurrentConfig returns the current configuration - GetCurrentConfig() interface{} - - // ApplyConfig applies the new configuration - ApplyConfig(config interface{}) error -} - -// UITemplRegistry manages task UI providers that use templ components -type UITemplRegistry struct { - providers map[TaskType]TaskUITemplProvider -} - -// NewUITemplRegistry creates a new templ-based UI registry -func NewUITemplRegistry() *UITemplRegistry { - return &UITemplRegistry{ - providers: make(map[TaskType]TaskUITemplProvider), - } -} - -// RegisterUI registers a task UI provider -func (r *UITemplRegistry) RegisterUI(provider TaskUITemplProvider) { - r.providers[provider.GetTaskType()] = provider -} - -// GetProvider returns the UI provider for a task type -func (r *UITemplRegistry) GetProvider(taskType TaskType) TaskUITemplProvider { - return r.providers[taskType] -} - -// GetAllProviders returns all registered UI providers -func (r *UITemplRegistry) GetAllProviders() map[TaskType]TaskUITemplProvider { - result := make(map[TaskType]TaskUITemplProvider) - for k, v := range r.providers { - result[k] = v - } - return result -} |
