diff options
Diffstat (limited to 'weed/admin')
26 files changed, 3478 insertions, 368 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 { |
