aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Lu <chrislusf@users.noreply.github.com>2025-07-12 01:13:11 -0700
committerGitHub <noreply@github.com>2025-07-12 01:13:11 -0700
commit687a6a6c1de0fb67b51ec9bfd1781a6c255ff695 (patch)
tree3ee2890c890e67a170cec2692425528aa9cd795f
parent49d43003e1f5063c57cd1b122469c0cb68d0cd79 (diff)
downloadseaweedfs-687a6a6c1de0fb67b51ec9bfd1781a6c255ff695.tar.xz
seaweedfs-687a6a6c1de0fb67b51ec9bfd1781a6c255ff695.zip
Admin UI: Add policies (#6968)
* add policies to UI, accessing filer directly * view, edit policies * add back buttons for "users" page * remove unused * fix ui dark mode when modal is closed * bucket view details button * fix browser buttons * filer action button works * clean up masters page * fix volume servers action buttons * fix collections page action button * fix properties page * more obvious * fix directory creation file mode * Update file_browser_handlers.go * directory permission
-rw-r--r--weed/admin/dash/admin_server.go1
-rw-r--r--weed/admin/dash/file_browser_data.go80
-rw-r--r--weed/admin/dash/file_mode_utils.go85
-rw-r--r--weed/admin/dash/policies_management.go225
-rw-r--r--weed/admin/handlers/admin_handlers.go27
-rw-r--r--weed/admin/handlers/file_browser_handlers.go15
-rw-r--r--weed/admin/handlers/maintenance_handlers.go233
-rw-r--r--weed/admin/handlers/policy_handlers.go273
-rw-r--r--weed/admin/view/app/cluster_collections.templ215
-rw-r--r--weed/admin/view/app/cluster_collections_templ.go87
-rw-r--r--weed/admin/view/app/cluster_filers.templ56
-rw-r--r--weed/admin/view/app/cluster_filers_templ.go2
-rw-r--r--weed/admin/view/app/cluster_masters.templ123
-rw-r--r--weed/admin/view/app/cluster_masters_templ.go57
-rw-r--r--weed/admin/view/app/cluster_volume_servers.templ181
-rw-r--r--weed/admin/view/app/cluster_volume_servers_templ.go148
-rw-r--r--weed/admin/view/app/file_browser.templ376
-rw-r--r--weed/admin/view/app/file_browser_templ.go98
-rw-r--r--weed/admin/view/app/object_store_users.templ350
-rw-r--r--weed/admin/view/app/object_store_users_templ.go2
-rw-r--r--weed/admin/view/app/policies.templ658
-rw-r--r--weed/admin/view/app/policies_templ.go204
-rw-r--r--weed/admin/view/app/s3_buckets.templ299
-rw-r--r--weed/admin/view/app/s3_buckets_templ.go22
-rw-r--r--weed/admin/view/layout/layout.templ5
-rw-r--r--weed/admin/view/layout/layout_templ.go24
-rw-r--r--weed/credential/credential_store.go22
-rw-r--r--weed/credential/filer_etc/filer_etc_identity.go188
-rw-r--r--weed/credential/filer_etc/filer_etc_policy.go114
-rw-r--r--weed/credential/filer_etc/filer_etc_store.go180
-rw-r--r--weed/credential/memory/memory_identity.go302
-rw-r--r--weed/credential/memory/memory_policy.go77
-rw-r--r--weed/credential/memory/memory_store.go303
-rw-r--r--weed/credential/postgres/postgres_identity.go446
-rw-r--r--weed/credential/postgres/postgres_policy.go130
-rw-r--r--weed/credential/postgres/postgres_store.go449
-rw-r--r--weed/credential/test/policy_test.go146
-rw-r--r--weed/worker/tasks/balance/ui_templ.go369
-rw-r--r--weed/worker/tasks/erasure_coding/ui_templ.go319
-rw-r--r--weed/worker/tasks/vacuum/ui_templ.go330
-rw-r--r--weed/worker/types/task_ui_templ.go63
41 files changed, 4921 insertions, 2363 deletions
diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go
index 95bff6deb..9ae5c6ebd 100644
--- a/weed/admin/dash/admin_server.go
+++ b/weed/admin/dash/admin_server.go
@@ -94,6 +94,7 @@ func NewAdminServer(masterAddress string, templateFS http.FileSystem, dataDir st
glog.V(1).Infof("Set filer client for credential manager: %s", filerAddr)
break
}
+ glog.V(1).Infof("Waiting for filer discovery for credential manager...")
time.Sleep(5 * time.Second) // Retry every 5 seconds
}
}()
diff --git a/weed/admin/dash/file_browser_data.go b/weed/admin/dash/file_browser_data.go
index 3cb878718..6bb30c469 100644
--- a/weed/admin/dash/file_browser_data.go
+++ b/weed/admin/dash/file_browser_data.go
@@ -99,7 +99,7 @@ func (s *AdminServer) GetFileBrowser(path string) (*FileBrowserData, error) {
var ttlSec int32
if entry.Attributes != nil {
- mode = formatFileMode(entry.Attributes.FileMode)
+ mode = FormatFileMode(entry.Attributes.FileMode)
uid = entry.Attributes.Uid
gid = entry.Attributes.Gid
size = int64(entry.Attributes.FileSize)
@@ -270,81 +270,3 @@ func (s *AdminServer) generateBreadcrumbs(path string) []BreadcrumbItem {
return breadcrumbs
}
-
-// formatFileMode converts file mode to Unix-style string representation (e.g., "drwxr-xr-x")
-func formatFileMode(mode uint32) string {
- var result []byte = make([]byte, 10)
-
- // File type
- switch mode & 0170000 { // S_IFMT mask
- case 0040000: // S_IFDIR
- result[0] = 'd'
- case 0100000: // S_IFREG
- result[0] = '-'
- case 0120000: // S_IFLNK
- result[0] = 'l'
- case 0020000: // S_IFCHR
- result[0] = 'c'
- case 0060000: // S_IFBLK
- result[0] = 'b'
- case 0010000: // S_IFIFO
- result[0] = 'p'
- case 0140000: // S_IFSOCK
- result[0] = 's'
- default:
- result[0] = '-' // S_IFREG is default
- }
-
- // Owner permissions
- if mode&0400 != 0 { // S_IRUSR
- result[1] = 'r'
- } else {
- result[1] = '-'
- }
- if mode&0200 != 0 { // S_IWUSR
- result[2] = 'w'
- } else {
- result[2] = '-'
- }
- if mode&0100 != 0 { // S_IXUSR
- result[3] = 'x'
- } else {
- result[3] = '-'
- }
-
- // Group permissions
- if mode&0040 != 0 { // S_IRGRP
- result[4] = 'r'
- } else {
- result[4] = '-'
- }
- if mode&0020 != 0 { // S_IWGRP
- result[5] = 'w'
- } else {
- result[5] = '-'
- }
- if mode&0010 != 0 { // S_IXGRP
- result[6] = 'x'
- } else {
- result[6] = '-'
- }
-
- // Other permissions
- if mode&0004 != 0 { // S_IROTH
- result[7] = 'r'
- } else {
- result[7] = '-'
- }
- if mode&0002 != 0 { // S_IWOTH
- result[8] = 'w'
- } else {
- result[8] = '-'
- }
- if mode&0001 != 0 { // S_IXOTH
- result[9] = 'x'
- } else {
- result[9] = '-'
- }
-
- return string(result)
-}
diff --git a/weed/admin/dash/file_mode_utils.go b/weed/admin/dash/file_mode_utils.go
new file mode 100644
index 000000000..19c5b2f49
--- /dev/null
+++ b/weed/admin/dash/file_mode_utils.go
@@ -0,0 +1,85 @@
+package dash
+
+// FormatFileMode converts file mode to Unix-style string representation (e.g., "drwxr-xr-x")
+// Handles both Go's os.ModeDir format and standard Unix file type bits
+func FormatFileMode(mode uint32) string {
+ var result []byte = make([]byte, 10)
+
+ // File type - handle Go's os.ModeDir first, then standard Unix file type bits
+ if mode&0x80000000 != 0 { // Go's os.ModeDir (0x80000000 = 2147483648)
+ result[0] = 'd'
+ } else {
+ switch mode & 0170000 { // S_IFMT mask
+ case 0040000: // S_IFDIR
+ result[0] = 'd'
+ case 0100000: // S_IFREG
+ result[0] = '-'
+ case 0120000: // S_IFLNK
+ result[0] = 'l'
+ case 0020000: // S_IFCHR
+ result[0] = 'c'
+ case 0060000: // S_IFBLK
+ result[0] = 'b'
+ case 0010000: // S_IFIFO
+ result[0] = 'p'
+ case 0140000: // S_IFSOCK
+ result[0] = 's'
+ default:
+ result[0] = '-' // S_IFREG is default
+ }
+ }
+
+ // Permission bits (always use the lower 12 bits regardless of file type format)
+ // Owner permissions
+ if mode&0400 != 0 { // S_IRUSR
+ result[1] = 'r'
+ } else {
+ result[1] = '-'
+ }
+ if mode&0200 != 0 { // S_IWUSR
+ result[2] = 'w'
+ } else {
+ result[2] = '-'
+ }
+ if mode&0100 != 0 { // S_IXUSR
+ result[3] = 'x'
+ } else {
+ result[3] = '-'
+ }
+
+ // Group permissions
+ if mode&0040 != 0 { // S_IRGRP
+ result[4] = 'r'
+ } else {
+ result[4] = '-'
+ }
+ if mode&0020 != 0 { // S_IWGRP
+ result[5] = 'w'
+ } else {
+ result[5] = '-'
+ }
+ if mode&0010 != 0 { // S_IXGRP
+ result[6] = 'x'
+ } else {
+ result[6] = '-'
+ }
+
+ // Other permissions
+ if mode&0004 != 0 { // S_IROTH
+ result[7] = 'r'
+ } else {
+ result[7] = '-'
+ }
+ if mode&0002 != 0 { // S_IWOTH
+ result[8] = 'w'
+ } else {
+ result[8] = '-'
+ }
+ if mode&0001 != 0 { // S_IXOTH
+ result[9] = 'x'
+ } else {
+ result[9] = '-'
+ }
+
+ return string(result)
+}
diff --git a/weed/admin/dash/policies_management.go b/weed/admin/dash/policies_management.go
new file mode 100644
index 000000000..8853bbb54
--- /dev/null
+++ b/weed/admin/dash/policies_management.go
@@ -0,0 +1,225 @@
+package dash
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+)
+
+type IAMPolicy struct {
+ Name string `json:"name"`
+ Document credential.PolicyDocument `json:"document"`
+ DocumentJSON string `json:"document_json"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type PoliciesCollection struct {
+ Policies map[string]credential.PolicyDocument `json:"policies"`
+}
+
+type PoliciesData struct {
+ Username string `json:"username"`
+ Policies []IAMPolicy `json:"policies"`
+ TotalPolicies int `json:"total_policies"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+// Policy management request structures
+type CreatePolicyRequest struct {
+ Name string `json:"name" binding:"required"`
+ Document credential.PolicyDocument `json:"document" binding:"required"`
+ DocumentJSON string `json:"document_json"`
+}
+
+type UpdatePolicyRequest struct {
+ Document credential.PolicyDocument `json:"document" binding:"required"`
+ DocumentJSON string `json:"document_json"`
+}
+
+// PolicyManager interface is now in the credential package
+
+// CredentialStorePolicyManager implements credential.PolicyManager by delegating to the credential store
+type CredentialStorePolicyManager struct {
+ credentialManager *credential.CredentialManager
+}
+
+// NewCredentialStorePolicyManager creates a new CredentialStorePolicyManager
+func NewCredentialStorePolicyManager(credentialManager *credential.CredentialManager) *CredentialStorePolicyManager {
+ return &CredentialStorePolicyManager{
+ credentialManager: credentialManager,
+ }
+}
+
+// GetPolicies retrieves all IAM policies via credential store
+func (cspm *CredentialStorePolicyManager) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) {
+ // Get policies from credential store
+ // We'll use the credential store to access the filer indirectly
+ // Since policies are stored separately, we need to access the underlying store
+ store := cspm.credentialManager.GetStore()
+ glog.V(1).Infof("Getting policies from credential store: %T", store)
+
+ // Check if the store supports policy management
+ if policyStore, ok := store.(credential.PolicyManager); ok {
+ glog.V(1).Infof("Store supports policy management, calling GetPolicies")
+ policies, err := policyStore.GetPolicies(ctx)
+ if err != nil {
+ glog.Errorf("Error getting policies from store: %v", err)
+ return nil, err
+ }
+ glog.V(1).Infof("Got %d policies from store", len(policies))
+ return policies, nil
+ } else {
+ // Fallback: use empty policies for stores that don't support policies
+ glog.V(1).Infof("Credential store doesn't support policy management, returning empty policies")
+ return make(map[string]credential.PolicyDocument), nil
+ }
+}
+
+// CreatePolicy creates a new IAM policy via credential store
+func (cspm *CredentialStorePolicyManager) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
+ store := cspm.credentialManager.GetStore()
+
+ if policyStore, ok := store.(credential.PolicyManager); ok {
+ return policyStore.CreatePolicy(ctx, name, document)
+ }
+
+ return fmt.Errorf("credential store doesn't support policy creation")
+}
+
+// UpdatePolicy updates an existing IAM policy via credential store
+func (cspm *CredentialStorePolicyManager) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
+ store := cspm.credentialManager.GetStore()
+
+ if policyStore, ok := store.(credential.PolicyManager); ok {
+ return policyStore.UpdatePolicy(ctx, name, document)
+ }
+
+ return fmt.Errorf("credential store doesn't support policy updates")
+}
+
+// DeletePolicy deletes an IAM policy via credential store
+func (cspm *CredentialStorePolicyManager) DeletePolicy(ctx context.Context, name string) error {
+ store := cspm.credentialManager.GetStore()
+
+ if policyStore, ok := store.(credential.PolicyManager); ok {
+ return policyStore.DeletePolicy(ctx, name)
+ }
+
+ return fmt.Errorf("credential store doesn't support policy deletion")
+}
+
+// GetPolicy retrieves a specific IAM policy via credential store
+func (cspm *CredentialStorePolicyManager) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) {
+ store := cspm.credentialManager.GetStore()
+
+ if policyStore, ok := store.(credential.PolicyManager); ok {
+ return policyStore.GetPolicy(ctx, name)
+ }
+
+ return nil, fmt.Errorf("credential store doesn't support policy retrieval")
+}
+
+// AdminServer policy management methods using credential.PolicyManager
+func (s *AdminServer) GetPolicyManager() credential.PolicyManager {
+ if s.credentialManager == nil {
+ glog.V(1).Infof("Credential manager is nil, policy management not available")
+ return nil
+ }
+ glog.V(1).Infof("Credential manager available, creating CredentialStorePolicyManager")
+ return NewCredentialStorePolicyManager(s.credentialManager)
+}
+
+// GetPolicies retrieves all IAM policies
+func (s *AdminServer) GetPolicies() ([]IAMPolicy, error) {
+ policyManager := s.GetPolicyManager()
+ if policyManager == nil {
+ return nil, fmt.Errorf("policy manager not available")
+ }
+
+ ctx := context.Background()
+ policyMap, err := policyManager.GetPolicies(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Convert map[string]PolicyDocument to []IAMPolicy
+ var policies []IAMPolicy
+ for name, doc := range policyMap {
+ policy := IAMPolicy{
+ Name: name,
+ Document: doc,
+ DocumentJSON: "", // Will be populated if needed
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ policies = append(policies, policy)
+ }
+
+ return policies, nil
+}
+
+// CreatePolicy creates a new IAM policy
+func (s *AdminServer) CreatePolicy(name string, document credential.PolicyDocument) error {
+ policyManager := s.GetPolicyManager()
+ if policyManager == nil {
+ return fmt.Errorf("policy manager not available")
+ }
+
+ ctx := context.Background()
+ return policyManager.CreatePolicy(ctx, name, document)
+}
+
+// UpdatePolicy updates an existing IAM policy
+func (s *AdminServer) UpdatePolicy(name string, document credential.PolicyDocument) error {
+ policyManager := s.GetPolicyManager()
+ if policyManager == nil {
+ return fmt.Errorf("policy manager not available")
+ }
+
+ ctx := context.Background()
+ return policyManager.UpdatePolicy(ctx, name, document)
+}
+
+// DeletePolicy deletes an IAM policy
+func (s *AdminServer) DeletePolicy(name string) error {
+ policyManager := s.GetPolicyManager()
+ if policyManager == nil {
+ return fmt.Errorf("policy manager not available")
+ }
+
+ ctx := context.Background()
+ return policyManager.DeletePolicy(ctx, name)
+}
+
+// GetPolicy retrieves a specific IAM policy
+func (s *AdminServer) GetPolicy(name string) (*IAMPolicy, error) {
+ policyManager := s.GetPolicyManager()
+ if policyManager == nil {
+ return nil, fmt.Errorf("policy manager not available")
+ }
+
+ ctx := context.Background()
+ policyDoc, err := policyManager.GetPolicy(ctx, name)
+ if err != nil {
+ return nil, err
+ }
+
+ if policyDoc == nil {
+ return nil, nil
+ }
+
+ // Convert PolicyDocument to IAMPolicy
+ policy := &IAMPolicy{
+ Name: name,
+ Document: *policyDoc,
+ DocumentJSON: "", // Will be populated if needed
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+
+ return policy, nil
+}
diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go
index dc7905bc1..76a123a4f 100644
--- a/weed/admin/handlers/admin_handlers.go
+++ b/weed/admin/handlers/admin_handlers.go
@@ -17,6 +17,7 @@ type AdminHandlers struct {
clusterHandlers *ClusterHandlers
fileBrowserHandlers *FileBrowserHandlers
userHandlers *UserHandlers
+ policyHandlers *PolicyHandlers
maintenanceHandlers *MaintenanceHandlers
mqHandlers *MessageQueueHandlers
}
@@ -27,6 +28,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
clusterHandlers := NewClusterHandlers(adminServer)
fileBrowserHandlers := NewFileBrowserHandlers(adminServer)
userHandlers := NewUserHandlers(adminServer)
+ policyHandlers := NewPolicyHandlers(adminServer)
maintenanceHandlers := NewMaintenanceHandlers(adminServer)
mqHandlers := NewMessageQueueHandlers(adminServer)
return &AdminHandlers{
@@ -35,6 +37,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
clusterHandlers: clusterHandlers,
fileBrowserHandlers: fileBrowserHandlers,
userHandlers: userHandlers,
+ policyHandlers: policyHandlers,
maintenanceHandlers: maintenanceHandlers,
mqHandlers: mqHandlers,
}
@@ -63,6 +66,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
protected.GET("/object-store/buckets", h.ShowS3Buckets)
protected.GET("/object-store/buckets/:bucket", h.ShowBucketDetails)
protected.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
+ protected.GET("/object-store/policies", h.policyHandlers.ShowPolicies)
// File browser routes
protected.GET("/files", h.fileBrowserHandlers.ShowFileBrowser)
@@ -121,6 +125,17 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies)
}
+ // Object Store Policy management API routes
+ objectStorePoliciesApi := api.Group("/object-store/policies")
+ {
+ objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies)
+ objectStorePoliciesApi.POST("", h.policyHandlers.CreatePolicy)
+ objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy)
+ objectStorePoliciesApi.PUT("/:name", h.policyHandlers.UpdatePolicy)
+ objectStorePoliciesApi.DELETE("/:name", h.policyHandlers.DeletePolicy)
+ objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy)
+ }
+
// File management API routes
filesApi := api.Group("/files")
{
@@ -171,6 +186,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
r.GET("/object-store/buckets", h.ShowS3Buckets)
r.GET("/object-store/buckets/:bucket", h.ShowBucketDetails)
r.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
+ r.GET("/object-store/policies", h.policyHandlers.ShowPolicies)
// File browser routes
r.GET("/files", h.fileBrowserHandlers.ShowFileBrowser)
@@ -229,6 +245,17 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies)
}
+ // Object Store Policy management API routes
+ objectStorePoliciesApi := api.Group("/object-store/policies")
+ {
+ objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies)
+ objectStorePoliciesApi.POST("", h.policyHandlers.CreatePolicy)
+ objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy)
+ objectStorePoliciesApi.PUT("/:name", h.policyHandlers.UpdatePolicy)
+ objectStorePoliciesApi.DELETE("/:name", h.policyHandlers.DeletePolicy)
+ objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy)
+ }
+
// File management API routes
filesApi := api.Group("/files")
{
diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go
index 97621192e..c8e117041 100644
--- a/weed/admin/handlers/file_browser_handlers.go
+++ b/weed/admin/handlers/file_browser_handlers.go
@@ -8,6 +8,7 @@ import (
"mime/multipart"
"net"
"net/http"
+ "os"
"path/filepath"
"strconv"
"strings"
@@ -190,7 +191,7 @@ func (h *FileBrowserHandlers) CreateFolder(c *gin.Context) {
Name: filepath.Base(fullPath),
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
- FileMode: uint32(0755 | (1 << 31)), // Directory mode
+ FileMode: uint32(0755 | os.ModeDir), // Directory mode
Uid: filer_pb.OS_UID,
Gid: filer_pb.OS_GID,
Crtime: time.Now().Unix(),
@@ -656,8 +657,9 @@ func (h *FileBrowserHandlers) GetFileProperties(c *gin.Context) {
properties["created_timestamp"] = entry.Attributes.Crtime
}
- properties["file_mode"] = fmt.Sprintf("%o", entry.Attributes.FileMode)
- properties["file_mode_formatted"] = h.formatFileMode(entry.Attributes.FileMode)
+ properties["file_mode"] = dash.FormatFileMode(entry.Attributes.FileMode)
+ properties["file_mode_formatted"] = dash.FormatFileMode(entry.Attributes.FileMode)
+ properties["file_mode_octal"] = fmt.Sprintf("%o", entry.Attributes.FileMode)
properties["uid"] = entry.Attributes.Uid
properties["gid"] = entry.Attributes.Gid
properties["ttl_seconds"] = entry.Attributes.TtlSec
@@ -725,13 +727,6 @@ func (h *FileBrowserHandlers) formatBytes(bytes int64) string {
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
-// Helper function to format file mode
-func (h *FileBrowserHandlers) formatFileMode(mode uint32) string {
- // Convert to octal and format as rwx permissions
- perm := mode & 0777
- return fmt.Sprintf("%03o", perm)
-}
-
// Helper function to determine MIME type from filename
func (h *FileBrowserHandlers) determineMimeType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
diff --git a/weed/admin/handlers/maintenance_handlers.go b/weed/admin/handlers/maintenance_handlers.go
index 954874c14..4b1f91387 100644
--- a/weed/admin/handlers/maintenance_handlers.go
+++ b/weed/admin/handlers/maintenance_handlers.go
@@ -11,9 +11,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/admin/view/components"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
- "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
- "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
- "github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
@@ -114,59 +111,60 @@ func (h *MaintenanceHandlers) ShowTaskConfig(c *gin.Context) {
return
}
- // Try to get templ UI provider first
- templUIProvider := getTemplUIProvider(taskType)
+ // Try to get templ UI provider first - temporarily disabled
+ // templUIProvider := getTemplUIProvider(taskType)
var configSections []components.ConfigSectionData
- if templUIProvider != nil {
- // Use the new templ-based UI provider
- currentConfig := templUIProvider.GetCurrentConfig()
- sections, err := templUIProvider.RenderConfigSections(currentConfig)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render configuration sections: " + err.Error()})
- return
- }
- configSections = sections
- } else {
- // Fallback to basic configuration for providers that haven't been migrated yet
- configSections = []components.ConfigSectionData{
- {
- Title: "Configuration Settings",
- Icon: "fas fa-cogs",
- Description: "Configure task detection and scheduling parameters",
- Fields: []interface{}{
- components.CheckboxFieldData{
- FormFieldData: components.FormFieldData{
- Name: "enabled",
- Label: "Enable Task",
- Description: "Whether this task type should be enabled",
- },
- Checked: true,
+ // Temporarily disabled templ UI provider
+ // if templUIProvider != nil {
+ // // Use the new templ-based UI provider
+ // currentConfig := templUIProvider.GetCurrentConfig()
+ // sections, err := templUIProvider.RenderConfigSections(currentConfig)
+ // if err != nil {
+ // c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render configuration sections: " + err.Error()})
+ // return
+ // }
+ // configSections = sections
+ // } else {
+ // Fallback to basic configuration for providers that haven't been migrated yet
+ configSections = []components.ConfigSectionData{
+ {
+ Title: "Configuration Settings",
+ Icon: "fas fa-cogs",
+ Description: "Configure task detection and scheduling parameters",
+ Fields: []interface{}{
+ components.CheckboxFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "enabled",
+ Label: "Enable Task",
+ Description: "Whether this task type should be enabled",
},
- components.NumberFieldData{
- FormFieldData: components.FormFieldData{
- Name: "max_concurrent",
- Label: "Max Concurrent Tasks",
- Description: "Maximum number of concurrent tasks",
- Required: true,
- },
- Value: 2,
- Step: "1",
- Min: floatPtr(1),
+ Checked: true,
+ },
+ components.NumberFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "max_concurrent",
+ Label: "Max Concurrent Tasks",
+ Description: "Maximum number of concurrent tasks",
+ Required: true,
},
- components.DurationFieldData{
- FormFieldData: components.FormFieldData{
- Name: "scan_interval",
- Label: "Scan Interval",
- Description: "How often to scan for tasks",
- Required: true,
- },
- Value: "30m",
+ Value: 2,
+ Step: "1",
+ Min: floatPtr(1),
+ },
+ components.DurationFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "scan_interval",
+ Label: "Scan Interval",
+ Description: "How often to scan for tasks",
+ Required: true,
},
+ Value: "30m",
},
},
- }
+ },
}
+ // } // End of disabled templ UI provider else block
// Create task configuration data using templ components
configData := &app.TaskConfigTemplData{
@@ -199,8 +197,8 @@ func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) {
return
}
- // Try to get templ UI provider first
- templUIProvider := getTemplUIProvider(taskType)
+ // Try to get templ UI provider first - temporarily disabled
+ // templUIProvider := getTemplUIProvider(taskType)
// Parse form data
err := c.Request.ParseForm()
@@ -217,52 +215,53 @@ func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) {
var config interface{}
- if templUIProvider != nil {
- // Use the new templ-based UI provider
- config, err = templUIProvider.ParseConfigForm(formData)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
- return
- }
+ // Temporarily disabled templ UI provider
+ // if templUIProvider != nil {
+ // // Use the new templ-based UI provider
+ // config, err = templUIProvider.ParseConfigForm(formData)
+ // if err != nil {
+ // c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
+ // return
+ // }
+ // // Apply configuration using templ provider
+ // err = templUIProvider.ApplyConfig(config)
+ // if err != nil {
+ // c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
+ // return
+ // }
+ // } else {
+ // Fallback to old UI provider for tasks that haven't been migrated yet
+ // Fallback to old UI provider for tasks that haven't been migrated yet
+ uiRegistry := tasks.GetGlobalUIRegistry()
+ typesRegistry := tasks.GetGlobalTypesRegistry()
- // Apply configuration using templ provider
- err = templUIProvider.ApplyConfig(config)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
- return
- }
- } else {
- // Fallback to old UI provider for tasks that haven't been migrated yet
- uiRegistry := tasks.GetGlobalUIRegistry()
- typesRegistry := tasks.GetGlobalTypesRegistry()
-
- var provider types.TaskUIProvider
- for workerTaskType := range typesRegistry.GetAllDetectors() {
- if string(workerTaskType) == string(taskType) {
- provider = uiRegistry.GetProvider(workerTaskType)
- break
- }
+ var provider types.TaskUIProvider
+ for workerTaskType := range typesRegistry.GetAllDetectors() {
+ if string(workerTaskType) == string(taskType) {
+ provider = uiRegistry.GetProvider(workerTaskType)
+ break
}
+ }
- if provider == nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "UI provider not found for task type"})
- return
- }
+ if provider == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "UI provider not found for task type"})
+ return
+ }
- // Parse configuration from form using old provider
- config, err = provider.ParseConfigForm(formData)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
- return
- }
+ // Parse configuration from form using old provider
+ config, err = provider.ParseConfigForm(formData)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
+ return
+ }
- // Apply configuration using old provider
- err = provider.ApplyConfig(config)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
- return
- }
+ // Apply configuration using old provider
+ err = provider.ApplyConfig(config)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
+ return
}
+ // } // End of disabled templ UI provider else block
// Redirect back to task configuration page
c.Redirect(http.StatusSeeOther, "/maintenance/config/"+taskTypeName)
@@ -350,39 +349,35 @@ func floatPtr(f float64) *float64 {
return &f
}
-// Global templ UI registry
-var globalTemplUIRegistry *types.UITemplRegistry
+// Global templ UI registry - temporarily disabled
+// var globalTemplUIRegistry *types.UITemplRegistry
-// initTemplUIRegistry initializes the global templ UI registry
+// initTemplUIRegistry initializes the global templ UI registry - temporarily disabled
func initTemplUIRegistry() {
- if globalTemplUIRegistry == nil {
- globalTemplUIRegistry = types.NewUITemplRegistry()
-
- // Register vacuum templ UI provider using shared instances
- vacuumDetector, vacuumScheduler := vacuum.GetSharedInstances()
- vacuum.RegisterUITempl(globalTemplUIRegistry, vacuumDetector, vacuumScheduler)
-
- // Register erasure coding templ UI provider using shared instances
- erasureCodingDetector, erasureCodingScheduler := erasure_coding.GetSharedInstances()
- erasure_coding.RegisterUITempl(globalTemplUIRegistry, erasureCodingDetector, erasureCodingScheduler)
-
- // Register balance templ UI provider using shared instances
- balanceDetector, balanceScheduler := balance.GetSharedInstances()
- balance.RegisterUITempl(globalTemplUIRegistry, balanceDetector, balanceScheduler)
- }
+ // Temporarily disabled due to missing types
+ // if globalTemplUIRegistry == nil {
+ // globalTemplUIRegistry = types.NewUITemplRegistry()
+ // // Register vacuum templ UI provider using shared instances
+ // vacuumDetector, vacuumScheduler := vacuum.GetSharedInstances()
+ // vacuum.RegisterUITempl(globalTemplUIRegistry, vacuumDetector, vacuumScheduler)
+ // // Register erasure coding templ UI provider using shared instances
+ // erasureCodingDetector, erasureCodingScheduler := erasure_coding.GetSharedInstances()
+ // erasure_coding.RegisterUITempl(globalTemplUIRegistry, erasureCodingDetector, erasureCodingScheduler)
+ // // Register balance templ UI provider using shared instances
+ // balanceDetector, balanceScheduler := balance.GetSharedInstances()
+ // balance.RegisterUITempl(globalTemplUIRegistry, balanceDetector, balanceScheduler)
+ // }
}
-// getTemplUIProvider gets the templ UI provider for a task type
-func getTemplUIProvider(taskType maintenance.MaintenanceTaskType) types.TaskUITemplProvider {
- initTemplUIRegistry()
-
+// getTemplUIProvider gets the templ UI provider for a task type - temporarily disabled
+func getTemplUIProvider(taskType maintenance.MaintenanceTaskType) interface{} {
+ // initTemplUIRegistry()
// Convert maintenance task type to worker task type
- typesRegistry := tasks.GetGlobalTypesRegistry()
- for workerTaskType := range typesRegistry.GetAllDetectors() {
- if string(workerTaskType) == string(taskType) {
- return globalTemplUIRegistry.GetProvider(workerTaskType)
- }
- }
-
+ // typesRegistry := tasks.GetGlobalTypesRegistry()
+ // for workerTaskType := range typesRegistry.GetAllDetectors() {
+ // if string(workerTaskType) == string(taskType) {
+ // return globalTemplUIRegistry.GetProvider(workerTaskType)
+ // }
+ // }
return nil
}
diff --git a/weed/admin/handlers/policy_handlers.go b/weed/admin/handlers/policy_handlers.go
new file mode 100644
index 000000000..8f5cc91b1
--- /dev/null
+++ b/weed/admin/handlers/policy_handlers.go
@@ -0,0 +1,273 @@
+package handlers
+
+import (
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/seaweedfs/seaweedfs/weed/admin/dash"
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/app"
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+)
+
+// PolicyHandlers contains all the HTTP handlers for policy management
+type PolicyHandlers struct {
+ adminServer *dash.AdminServer
+}
+
+// NewPolicyHandlers creates a new instance of PolicyHandlers
+func NewPolicyHandlers(adminServer *dash.AdminServer) *PolicyHandlers {
+ return &PolicyHandlers{
+ adminServer: adminServer,
+ }
+}
+
+// ShowPolicies renders the policies management page
+func (h *PolicyHandlers) ShowPolicies(c *gin.Context) {
+ // Get policies data from the server
+ policiesData := h.getPoliciesData(c)
+
+ // Render HTML template
+ c.Header("Content-Type", "text/html")
+ policiesComponent := app.Policies(policiesData)
+ layoutComponent := layout.Layout(c, policiesComponent)
+ err := layoutComponent.Render(c.Request.Context(), c.Writer)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
+ return
+ }
+}
+
+// GetPolicies returns the list of policies as JSON
+func (h *PolicyHandlers) GetPolicies(c *gin.Context) {
+ policies, err := h.adminServer.GetPolicies()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policies: " + err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"policies": policies})
+}
+
+// CreatePolicy handles policy creation
+func (h *PolicyHandlers) CreatePolicy(c *gin.Context) {
+ var req dash.CreatePolicyRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+ return
+ }
+
+ // Validate policy name
+ if req.Name == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"})
+ return
+ }
+
+ // Check if policy already exists
+ existingPolicy, err := h.adminServer.GetPolicy(req.Name)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()})
+ return
+ }
+ if existingPolicy != nil {
+ c.JSON(http.StatusConflict, gin.H{"error": "Policy with this name already exists"})
+ return
+ }
+
+ // Create the policy
+ err = h.adminServer.CreatePolicy(req.Name, req.Document)
+ if err != nil {
+ glog.Errorf("Failed to create policy %s: %v", req.Name, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create policy: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusCreated, gin.H{
+ "success": true,
+ "message": "Policy created successfully",
+ "policy": req.Name,
+ })
+}
+
+// GetPolicy returns a specific policy
+func (h *PolicyHandlers) GetPolicy(c *gin.Context) {
+ policyName := c.Param("name")
+ if policyName == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"})
+ return
+ }
+
+ policy, err := h.adminServer.GetPolicy(policyName)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policy: " + err.Error()})
+ return
+ }
+
+ if policy == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, policy)
+}
+
+// UpdatePolicy handles policy updates
+func (h *PolicyHandlers) UpdatePolicy(c *gin.Context) {
+ policyName := c.Param("name")
+ if policyName == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"})
+ return
+ }
+
+ var req dash.UpdatePolicyRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+ return
+ }
+
+ // Check if policy exists
+ existingPolicy, err := h.adminServer.GetPolicy(policyName)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()})
+ return
+ }
+ if existingPolicy == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"})
+ return
+ }
+
+ // Update the policy
+ err = h.adminServer.UpdatePolicy(policyName, req.Document)
+ if err != nil {
+ glog.Errorf("Failed to update policy %s: %v", policyName, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update policy: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Policy updated successfully",
+ "policy": policyName,
+ })
+}
+
+// DeletePolicy handles policy deletion
+func (h *PolicyHandlers) DeletePolicy(c *gin.Context) {
+ policyName := c.Param("name")
+ if policyName == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"})
+ return
+ }
+
+ // Check if policy exists
+ existingPolicy, err := h.adminServer.GetPolicy(policyName)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()})
+ return
+ }
+ if existingPolicy == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"})
+ return
+ }
+
+ // Delete the policy
+ err = h.adminServer.DeletePolicy(policyName)
+ if err != nil {
+ glog.Errorf("Failed to delete policy %s: %v", policyName, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete policy: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Policy deleted successfully",
+ "policy": policyName,
+ })
+}
+
+// ValidatePolicy validates a policy document without saving it
+func (h *PolicyHandlers) ValidatePolicy(c *gin.Context) {
+ var req struct {
+ Document credential.PolicyDocument `json:"document" binding:"required"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+ return
+ }
+
+ // Basic validation
+ if req.Document.Version == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Policy version is required"})
+ return
+ }
+
+ if len(req.Document.Statement) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Policy must have at least one statement"})
+ return
+ }
+
+ // Validate each statement
+ for i, statement := range req.Document.Statement {
+ if statement.Effect != "Allow" && statement.Effect != "Deny" {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": fmt.Sprintf("Statement %d: Effect must be 'Allow' or 'Deny'", i+1),
+ })
+ return
+ }
+
+ if len(statement.Action) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": fmt.Sprintf("Statement %d: Action is required", i+1),
+ })
+ return
+ }
+
+ if len(statement.Resource) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": fmt.Sprintf("Statement %d: Resource is required", i+1),
+ })
+ return
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "valid": true,
+ "message": "Policy document is valid",
+ })
+}
+
+// getPoliciesData retrieves policies data from the server
+func (h *PolicyHandlers) getPoliciesData(c *gin.Context) dash.PoliciesData {
+ username := c.GetString("username")
+ if username == "" {
+ username = "admin"
+ }
+
+ // Get policies
+ policies, err := h.adminServer.GetPolicies()
+ if err != nil {
+ glog.Errorf("Failed to get policies: %v", err)
+ // Return empty data on error
+ return dash.PoliciesData{
+ Username: username,
+ Policies: []dash.IAMPolicy{},
+ TotalPolicies: 0,
+ LastUpdated: time.Now(),
+ }
+ }
+
+ // Ensure policies is never nil
+ if policies == nil {
+ policies = []dash.IAMPolicy{}
+ }
+
+ return dash.PoliciesData{
+ Username: username,
+ Policies: policies,
+ TotalPolicies: len(policies),
+ LastUpdated: time.Now(),
+ }
+}
diff --git a/weed/admin/view/app/cluster_collections.templ b/weed/admin/view/app/cluster_collections.templ
index 2bd21a3ca..9099fe112 100644
--- a/weed/admin/view/app/cluster_collections.templ
+++ b/weed/admin/view/app/cluster_collections.templ
@@ -164,22 +164,18 @@ templ ClusterCollections(data dash.ClusterCollectionsData) {
}
</td>
<td>
- <div class="btn-group btn-group-sm">
- <button type="button" class="btn btn-outline-primary btn-sm"
- title="View Details">
- <i class="fas fa-eye"></i>
- </button>
- <button type="button" class="btn btn-outline-secondary btn-sm"
- title="Edit">
- <i class="fas fa-edit"></i>
- </button>
- <button type="button" class="btn btn-outline-danger btn-sm"
- title="Delete"
- data-collection-name={collection.Name}
- onclick="confirmDeleteCollection(this)">
- <i class="fas fa-trash"></i>
- </button>
- </div>
+ <button type="button"
+ class="btn btn-outline-primary btn-sm"
+ title="View Details"
+ data-action="view-details"
+ data-name={collection.Name}
+ data-datacenter={collection.DataCenter}
+ data-volume-count={fmt.Sprintf("%d", collection.VolumeCount)}
+ data-file-count={fmt.Sprintf("%d", collection.FileCount)}
+ data-total-size={fmt.Sprintf("%d", collection.TotalSize)}
+ data-disk-types={formatDiskTypes(collection.DiskTypes)}>
+ <i class="fas fa-eye"></i>
+ </button>
</td>
</tr>
}
@@ -209,30 +205,169 @@ templ ClusterCollections(data dash.ClusterCollectionsData) {
- <!-- Delete Confirmation Modal -->
- <div class="modal fade" id="deleteCollectionModal" tabindex="-1">
- <div class="modal-dialog">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title text-danger">
- <i class="fas fa-exclamation-triangle me-2"></i>Delete Collection
- </h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
- </div>
- <div class="modal-body">
- <p>Are you sure you want to delete the collection <strong id="deleteCollectionName"></strong>?</p>
- <div class="alert alert-warning">
- <i class="fas fa-warning me-2"></i>
- This action cannot be undone. All volumes in this collection will be affected.
- </div>
- </div>
- <div class="modal-footer">
- <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
- <button type="button" class="btn btn-danger" id="confirmDeleteCollection">Delete Collection</button>
- </div>
- </div>
- </div>
- </div>
+
+
+ <!-- JavaScript for cluster collections functionality -->
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+ // Handle collection action buttons
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const action = button.getAttribute('data-action');
+
+ switch(action) {
+ case 'view-details':
+ const collectionData = {
+ name: button.getAttribute('data-name'),
+ datacenter: button.getAttribute('data-datacenter'),
+ volumeCount: parseInt(button.getAttribute('data-volume-count')),
+ fileCount: parseInt(button.getAttribute('data-file-count')),
+ totalSize: parseInt(button.getAttribute('data-total-size')),
+ diskTypes: button.getAttribute('data-disk-types')
+ };
+ showCollectionDetails(collectionData);
+ break;
+ }
+ });
+ });
+
+ function showCollectionDetails(collection) {
+ const modalHtml = '<div class="modal fade" id="collectionDetailsModal" tabindex="-1">' +
+ '<div class="modal-dialog modal-lg">' +
+ '<div class="modal-content">' +
+ '<div class="modal-header">' +
+ '<h5 class="modal-title"><i class="fas fa-layer-group me-2"></i>Collection Details: ' + collection.name + '</h5>' +
+ '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
+ '</div>' +
+ '<div class="modal-body">' +
+ '<div class="row">' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-info-circle me-1"></i>Basic Information</h6>' +
+ '<table class="table table-sm">' +
+ '<tr><td><strong>Collection Name:</strong></td><td><code>' + collection.name + '</code></td></tr>' +
+ '<tr><td><strong>Data Center:</strong></td><td>' +
+ (collection.datacenter ? '<span class="badge bg-light text-dark">' + collection.datacenter + '</span>' : '<span class="text-muted">N/A</span>') +
+ '</td></tr>' +
+ '<tr><td><strong>Disk Types:</strong></td><td>' +
+ (collection.diskTypes ? collection.diskTypes.split(', ').map(type =>
+ '<span class="badge bg-' + getDiskTypeBadgeColor(type) + ' me-1">' + type + '</span>'
+ ).join('') : '<span class="text-muted">Unknown</span>') +
+ '</td></tr>' +
+ '</table>' +
+ '</div>' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-chart-bar me-1"></i>Storage Statistics</h6>' +
+ '<table class="table table-sm">' +
+ '<tr><td><strong>Total Volumes:</strong></td><td>' +
+ '<div class="d-flex align-items-center">' +
+ '<i class="fas fa-database me-2 text-muted"></i>' +
+ '<span>' + collection.volumeCount.toLocaleString() + '</span>' +
+ '</div>' +
+ '</td></tr>' +
+ '<tr><td><strong>Total Files:</strong></td><td>' +
+ '<div class="d-flex align-items-center">' +
+ '<i class="fas fa-file me-2 text-muted"></i>' +
+ '<span>' + collection.fileCount.toLocaleString() + '</span>' +
+ '</div>' +
+ '</td></tr>' +
+ '<tr><td><strong>Total Size:</strong></td><td>' +
+ '<div class="d-flex align-items-center">' +
+ '<i class="fas fa-hdd me-2 text-muted"></i>' +
+ '<span>' + formatBytes(collection.totalSize) + '</span>' +
+ '</div>' +
+ '</td></tr>' +
+ '</table>' +
+ '</div>' +
+ '</div>' +
+ '<div class="row mt-3">' +
+ '<div class="col-12">' +
+ '<h6 class="text-primary"><i class="fas fa-link me-1"></i>Quick Actions</h6>' +
+ '<div class="d-grid gap-2 d-md-flex">' +
+ '<a href="/cluster/volumes?collection=' + encodeURIComponent(collection.name) + '" class="btn btn-outline-primary">' +
+ '<i class="fas fa-database me-1"></i>View Volumes' +
+ '</a>' +
+ '<a href="/files?collection=' + encodeURIComponent(collection.name) + '" class="btn btn-outline-info">' +
+ '<i class="fas fa-folder me-1"></i>Browse Files' +
+ '</a>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '<div class="modal-footer">' +
+ '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>';
+
+ // Remove existing modal if present
+ const existingModal = document.getElementById('collectionDetailsModal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // Add modal to body and show
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
+ const modal = new bootstrap.Modal(document.getElementById('collectionDetailsModal'));
+ modal.show();
+
+ // Remove modal when hidden
+ document.getElementById('collectionDetailsModal').addEventListener('hidden.bs.modal', function() {
+ this.remove();
+ });
+ }
+
+ function getDiskTypeBadgeColor(diskType) {
+ switch(diskType.toLowerCase()) {
+ case 'ssd':
+ return 'primary';
+ case 'hdd':
+ case '':
+ return 'secondary';
+ default:
+ return 'info';
+ }
+ }
+
+ function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ function exportCollections() {
+ // Simple CSV export of collections list
+ const rows = Array.from(document.querySelectorAll('#collectionsTable tbody tr')).map(row => {
+ const cells = row.querySelectorAll('td');
+ if (cells.length > 1) {
+ return {
+ name: cells[0].textContent.trim(),
+ volumes: cells[1].textContent.trim(),
+ files: cells[2].textContent.trim(),
+ size: cells[3].textContent.trim(),
+ diskTypes: cells[4].textContent.trim()
+ };
+ }
+ return null;
+ }).filter(row => row !== null);
+
+ const csvContent = "data:text/csv;charset=utf-8," +
+ "Collection Name,Volumes,Files,Size,Disk Types\n" +
+ rows.map(r => '"' + r.name + '","' + r.volumes + '","' + r.files + '","' + r.size + '","' + r.diskTypes + '"').join("\n");
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", "collections.csv");
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ </script>
}
func getDiskTypeColor(diskType string) string {
diff --git a/weed/admin/view/app/cluster_collections_templ.go b/weed/admin/view/app/cluster_collections_templ.go
index 8c675695a..58384c462 100644
--- a/weed/admin/view/app/cluster_collections_templ.go
+++ b/weed/admin/view/app/cluster_collections_templ.go
@@ -231,48 +231,113 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Edit\"><i class=\"fas fa-edit\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger btn-sm\" title=\"Delete\" data-collection-name=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\" data-action=\"view-details\" data-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 178, Col: 93}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 171, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" onclick=\"confirmDeleteCollection(this)\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-datacenter=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(collection.DataCenter)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 172, Col: 90}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-volume-count=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.VolumeCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 173, Col: 112}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-file-count=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.FileCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 174, Col: 108}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" data-total-size=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.TotalSize))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 175, Col: 108}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-disk-types=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var20 string
+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(formatDiskTypes(collection.DiskTypes))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 176, Col: 106}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"><i class=\"fas fa-eye\"></i></button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</tbody></table></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"text-center py-5\"><i class=\"fas fa-layer-group fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Collections Found</h5><p class=\"text-muted\">No collections are currently configured in the cluster.</p></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"text-center py-5\"><i class=\"fas fa-layer-group fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Collections Found</h5><p class=\"text-muted\">No collections are currently configured in the cluster.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var16 string
- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
+ var templ_7745c5c3_Var21 string
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 204, Col: 81}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 200, Col: 81}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</small></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteCollectionModal\" tabindex=\"-1\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title text-danger\"><i class=\"fas fa-exclamation-triangle me-2\"></i>Delete Collection</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the collection <strong id=\"deleteCollectionName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-warning me-2\"></i> This action cannot be undone. All volumes in this collection will be affected.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" id=\"confirmDeleteCollection\">Delete Collection</button></div></div></div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</small></div></div></div><!-- JavaScript for cluster collections functionality --><script>\n document.addEventListener('DOMContentLoaded', function() {\n // Handle collection action buttons\n document.addEventListener('click', function(e) {\n const button = e.target.closest('[data-action]');\n if (!button) return;\n \n const action = button.getAttribute('data-action');\n \n switch(action) {\n case 'view-details':\n const collectionData = {\n name: button.getAttribute('data-name'),\n datacenter: button.getAttribute('data-datacenter'),\n volumeCount: parseInt(button.getAttribute('data-volume-count')),\n fileCount: parseInt(button.getAttribute('data-file-count')),\n totalSize: parseInt(button.getAttribute('data-total-size')),\n diskTypes: button.getAttribute('data-disk-types')\n };\n showCollectionDetails(collectionData);\n break;\n }\n });\n });\n \n function showCollectionDetails(collection) {\n const modalHtml = '<div class=\"modal fade\" id=\"collectionDetailsModal\" tabindex=\"-1\">' +\n '<div class=\"modal-dialog modal-lg\">' +\n '<div class=\"modal-content\">' +\n '<div class=\"modal-header\">' +\n '<h5 class=\"modal-title\"><i class=\"fas fa-layer-group me-2\"></i>Collection Details: ' + collection.name + '</h5>' +\n '<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n '</div>' +\n '<div class=\"modal-body\">' +\n '<div class=\"row\">' +\n '<div class=\"col-md-6\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-info-circle me-1\"></i>Basic Information</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr><td><strong>Collection Name:</strong></td><td><code>' + collection.name + '</code></td></tr>' +\n '<tr><td><strong>Data Center:</strong></td><td>' +\n (collection.datacenter ? '<span class=\"badge bg-light text-dark\">' + collection.datacenter + '</span>' : '<span class=\"text-muted\">N/A</span>') +\n '</td></tr>' +\n '<tr><td><strong>Disk Types:</strong></td><td>' +\n (collection.diskTypes ? collection.diskTypes.split(', ').map(type => \n '<span class=\"badge bg-' + getDiskTypeBadgeColor(type) + ' me-1\">' + type + '</span>'\n ).join('') : '<span class=\"text-muted\">Unknown</span>') +\n '</td></tr>' +\n '</table>' +\n '</div>' +\n '<div class=\"col-md-6\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-chart-bar me-1\"></i>Storage Statistics</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr><td><strong>Total Volumes:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<i class=\"fas fa-database me-2 text-muted\"></i>' +\n '<span>' + collection.volumeCount.toLocaleString() + '</span>' +\n '</div>' +\n '</td></tr>' +\n '<tr><td><strong>Total Files:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<i class=\"fas fa-file me-2 text-muted\"></i>' +\n '<span>' + collection.fileCount.toLocaleString() + '</span>' +\n '</div>' +\n '</td></tr>' +\n '<tr><td><strong>Total Size:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<i class=\"fas fa-hdd me-2 text-muted\"></i>' +\n '<span>' + formatBytes(collection.totalSize) + '</span>' +\n '</div>' +\n '</td></tr>' +\n '</table>' +\n '</div>' +\n '</div>' +\n '<div class=\"row mt-3\">' +\n '<div class=\"col-12\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-link me-1\"></i>Quick Actions</h6>' +\n '<div class=\"d-grid gap-2 d-md-flex\">' +\n '<a href=\"/cluster/volumes?collection=' + encodeURIComponent(collection.name) + '\" class=\"btn btn-outline-primary\">' +\n '<i class=\"fas fa-database me-1\"></i>View Volumes' +\n '</a>' +\n '<a href=\"/files?collection=' + encodeURIComponent(collection.name) + '\" class=\"btn btn-outline-info\">' +\n '<i class=\"fas fa-folder me-1\"></i>Browse Files' +\n '</a>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '<div class=\"modal-footer\">' +\n '<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '</div>';\n \n // Remove existing modal if present\n const existingModal = document.getElementById('collectionDetailsModal');\n if (existingModal) {\n existingModal.remove();\n }\n \n // Add modal to body and show\n document.body.insertAdjacentHTML('beforeend', modalHtml);\n const modal = new bootstrap.Modal(document.getElementById('collectionDetailsModal'));\n modal.show();\n \n // Remove modal when hidden\n document.getElementById('collectionDetailsModal').addEventListener('hidden.bs.modal', function() {\n this.remove();\n });\n }\n \n function getDiskTypeBadgeColor(diskType) {\n switch(diskType.toLowerCase()) {\n case 'ssd':\n return 'primary';\n case 'hdd':\n case '':\n return 'secondary';\n default:\n return 'info';\n }\n }\n \n function formatBytes(bytes) {\n if (bytes === 0) return '0 Bytes';\n const k = 1024;\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n }\n \n function exportCollections() {\n // Simple CSV export of collections list\n const rows = Array.from(document.querySelectorAll('#collectionsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n name: cells[0].textContent.trim(),\n volumes: cells[1].textContent.trim(),\n files: cells[2].textContent.trim(),\n size: cells[3].textContent.trim(),\n diskTypes: cells[4].textContent.trim()\n };\n }\n return null;\n }).filter(row => row !== null);\n \n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Collection Name,Volumes,Files,Size,Disk Types\\n\" +\n rows.map(r => '\"' + r.name + '\",\"' + r.volumes + '\",\"' + r.files + '\",\"' + r.size + '\",\"' + r.diskTypes + '\"').join(\"\\n\");\n \n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"collections.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/cluster_filers.templ b/weed/admin/view/app/cluster_filers.templ
index 6ed14ac6e..023fd4478 100644
--- a/weed/admin/view/app/cluster_filers.templ
+++ b/weed/admin/view/app/cluster_filers.templ
@@ -121,6 +121,62 @@ templ ClusterFilers(data dash.ClusterFilersData) {
</div>
</div>
</div>
+
+ <!-- JavaScript for cluster filers functionality -->
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+ // Handle filer action buttons
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const action = button.getAttribute('data-action');
+ const address = button.getAttribute('data-address');
+
+ if (!address) return;
+
+ switch(action) {
+ case 'open-filer':
+ openFilerBrowser(address);
+ break;
+ }
+ });
+ });
+
+ function openFilerBrowser(address) {
+ // Open file browser for specific filer
+ window.open('/files?filer=' + encodeURIComponent(address), '_blank');
+ }
+
+ function exportFilers() {
+ // Simple CSV export of filers list
+ const rows = Array.from(document.querySelectorAll('#filersTable tbody tr')).map(row => {
+ const cells = row.querySelectorAll('td');
+ if (cells.length > 1) {
+ return {
+ address: cells[0].textContent.trim(),
+ version: cells[1].textContent.trim(),
+ datacenter: cells[2].textContent.trim(),
+ rack: cells[3].textContent.trim(),
+ created: cells[4].textContent.trim()
+ };
+ }
+ return null;
+ }).filter(row => row !== null);
+
+ const csvContent = "data:text/csv;charset=utf-8," +
+ "Address,Version,Data Center,Rack,Created At\n" +
+ rows.map(r => '"' + r.address + '","' + r.version + '","' + r.datacenter + '","' + r.rack + '","' + r.created + '"').join("\n");
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", "filers.csv");
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ </script>
}
\ No newline at end of file
diff --git a/weed/admin/view/app/cluster_filers_templ.go b/weed/admin/view/app/cluster_filers_templ.go
index ecc2d873e..69c489ce4 100644
--- a/weed/admin/view/app/cluster_filers_templ.go
+++ b/weed/admin/view/app/cluster_filers_templ.go
@@ -183,7 +183,7 @@ func ClusterFilers(data dash.ClusterFilersData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></div></div><!-- JavaScript for cluster filers functionality --><script>\n\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t// Handle filer action buttons\n\t\tdocument.addEventListener('click', function(e) {\n\t\t\tconst button = e.target.closest('[data-action]');\n\t\t\tif (!button) return;\n\t\t\t\n\t\t\tconst action = button.getAttribute('data-action');\n\t\t\tconst address = button.getAttribute('data-address');\n\t\t\t\n\t\t\tif (!address) return;\n\t\t\t\n\t\t\tswitch(action) {\n\t\t\t\tcase 'open-filer':\n\t\t\t\t\topenFilerBrowser(address);\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t});\n\t});\n\t\n\tfunction openFilerBrowser(address) {\n\t\t// Open file browser for specific filer\n\t\twindow.open('/files?filer=' + encodeURIComponent(address), '_blank');\n\t}\n\t\n\tfunction exportFilers() {\n\t\t// Simple CSV export of filers list\n\t\tconst rows = Array.from(document.querySelectorAll('#filersTable tbody tr')).map(row => {\n\t\t\tconst cells = row.querySelectorAll('td');\n\t\t\tif (cells.length > 1) {\n\t\t\t\treturn {\n\t\t\t\t\taddress: cells[0].textContent.trim(),\n\t\t\t\t\tversion: cells[1].textContent.trim(),\n\t\t\t\t\tdatacenter: cells[2].textContent.trim(),\n\t\t\t\t\track: cells[3].textContent.trim(),\n\t\t\t\t\tcreated: cells[4].textContent.trim()\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t}).filter(row => row !== null);\n\t\t\n\t\tconst csvContent = \"data:text/csv;charset=utf-8,\" + \n\t\t\t\"Address,Version,Data Center,Rack,Created At\\n\" +\n\t\t\trows.map(r => '\"' + r.address + '\",\"' + r.version + '\",\"' + r.datacenter + '\",\"' + r.rack + '\",\"' + r.created + '\"').join(\"\\n\");\n\t\t\n\t\tconst encodedUri = encodeURI(csvContent);\n\t\tconst link = document.createElement(\"a\");\n\t\tlink.setAttribute(\"href\", encodedUri);\n\t\tlink.setAttribute(\"download\", \"filers.csv\");\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/cluster_masters.templ b/weed/admin/view/app/cluster_masters.templ
index 9f6e2d0a9..6a53c5493 100644
--- a/weed/admin/view/app/cluster_masters.templ
+++ b/weed/admin/view/app/cluster_masters.templ
@@ -136,14 +136,15 @@ templ ClusterMasters(data dash.ClusterMastersData) {
}
</td>
<td>
- <div class="btn-group btn-group-sm">
- <button type="button" class="btn btn-outline-primary btn-sm" title="View Details">
- <i class="fas fa-eye"></i>
- </button>
- <button type="button" class="btn btn-outline-secondary btn-sm" title="Manage">
- <i class="fas fa-cog"></i>
- </button>
- </div>
+ <button type="button"
+ class="btn btn-outline-primary btn-sm"
+ title="View Details"
+ data-action="view-details"
+ data-address={master.Address}
+ data-leader={fmt.Sprintf("%t", master.IsLeader)}
+ data-suffrage={master.Suffrage}>
+ <i class="fas fa-eye"></i>
+ </button>
</td>
</tr>
}
@@ -170,6 +171,112 @@ templ ClusterMasters(data dash.ClusterMastersData) {
</div>
</div>
</div>
+
+ <!-- JavaScript for cluster masters functionality -->
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+ // Handle master action buttons
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const action = button.getAttribute('data-action');
+ const address = button.getAttribute('data-address');
+
+ if (!address) return;
+
+ switch(action) {
+ case 'view-details':
+ const isLeader = button.getAttribute('data-leader') === 'true';
+ const suffrage = button.getAttribute('data-suffrage');
+ showMasterDetails(address, isLeader, suffrage);
+ break;
+ }
+ });
+ });
+
+ function showMasterDetails(address, isLeader, suffrage) {
+ const modalHtml = '<div class="modal fade" id="masterDetailsModal" tabindex="-1">' +
+ '<div class="modal-dialog modal-lg">' +
+ '<div class="modal-content">' +
+ '<div class="modal-header">' +
+ '<h5 class="modal-title"><i class="fas fa-crown me-2"></i>Master Details: ' + address + '</h5>' +
+ '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
+ '</div>' +
+ '<div class="modal-body">' +
+ '<div class="row">' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-info-circle me-1"></i>Basic Information</h6>' +
+ '<table class="table table-sm">' +
+ '<tr><td><strong>Address:</strong></td><td>' + address + '</td></tr>' +
+ '<tr><td><strong>Role:</strong></td><td>' +
+ (isLeader ? '<span class="badge bg-warning text-dark"><i class="fas fa-star me-1"></i>Leader</span>' :
+ '<span class="badge bg-secondary">Follower</span>') + '</td></tr>' +
+ '<tr><td><strong>Suffrage:</strong></td><td>' + (suffrage || 'N/A') + '</td></tr>' +
+ '<tr><td><strong>Status:</strong></td><td><span class="badge bg-success">Active</span></td></tr>' +
+ '</table>' +
+ '</div>' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-link me-1"></i>Quick Actions</h6>' +
+ '<div class="d-grid gap-2">' +
+ '<a href="http://' + address + '" target="_blank" class="btn btn-outline-primary">' +
+ '<i class="fas fa-external-link-alt me-1"></i>Open Master UI' +
+ '</a>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '<div class="modal-footer">' +
+ '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>';
+
+ // Remove existing modal if present
+ const existingModal = document.getElementById('masterDetailsModal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // Add modal to body and show
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
+ const modal = new bootstrap.Modal(document.getElementById('masterDetailsModal'));
+ modal.show();
+
+ // Remove modal when hidden
+ document.getElementById('masterDetailsModal').addEventListener('hidden.bs.modal', function() {
+ this.remove();
+ });
+ }
+
+ function exportMasters() {
+ // Simple CSV export of masters list
+ const rows = Array.from(document.querySelectorAll('#mastersTable tbody tr')).map(row => {
+ const cells = row.querySelectorAll('td');
+ if (cells.length > 1) {
+ return {
+ address: cells[0].textContent.trim(),
+ role: cells[1].textContent.trim(),
+ suffrage: cells[2].textContent.trim()
+ };
+ }
+ return null;
+ }).filter(row => row !== null);
+
+ const csvContent = "data:text/csv;charset=utf-8," +
+ "Address,Role,Suffrage\n" +
+ rows.map(r => '"' + r.address + '","' + r.role + '","' + r.suffrage + '"').join("\n");
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", "masters.csv");
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ </script>
}
\ No newline at end of file
diff --git a/weed/admin/view/app/cluster_masters_templ.go b/weed/admin/view/app/cluster_masters_templ.go
index 951db551e..e0be75cc4 100644
--- a/weed/admin/view/app/cluster_masters_templ.go
+++ b/weed/admin/view/app/cluster_masters_templ.go
@@ -154,35 +154,74 @@ func ClusterMasters(data dash.ClusterMastersData) templ.Component {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Manage\"><i class=\"fas fa-cog\"></i></button></div></td></tr>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\" data-action=\"view-details\" data-address=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(master.Address)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 143, Col: 41}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-leader=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", master.IsLeader))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 144, Col: 60}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-suffrage=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(master.Suffrage)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 145, Col: 43}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><i class=\"fas fa-eye\"></i></button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</tbody></table></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"text-center py-5\"><i class=\"fas fa-crown fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Masters Found</h5><p class=\"text-muted\">No master servers are currently available in the cluster.</p></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"text-center py-5\"><i class=\"fas fa-crown fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Masters Found</h5><p class=\"text-muted\">No master servers are currently available in the cluster.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var7 string
- templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 168, Col: 67}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 169, Col: 67}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</small></div></div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</small></div></div></div><!-- JavaScript for cluster masters functionality --><script>\n\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t// Handle master action buttons\n\t\tdocument.addEventListener('click', function(e) {\n\t\t\tconst button = e.target.closest('[data-action]');\n\t\t\tif (!button) return;\n\t\t\t\n\t\t\tconst action = button.getAttribute('data-action');\n\t\t\tconst address = button.getAttribute('data-address');\n\t\t\t\n\t\t\tif (!address) return;\n\t\t\t\n\t\t\tswitch(action) {\n\t\t\t\tcase 'view-details':\n\t\t\t\t\tconst isLeader = button.getAttribute('data-leader') === 'true';\n\t\t\t\t\tconst suffrage = button.getAttribute('data-suffrage');\n\t\t\t\t\tshowMasterDetails(address, isLeader, suffrage);\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t});\n\t});\n\t\n\tfunction showMasterDetails(address, isLeader, suffrage) {\n\t\tconst modalHtml = '<div class=\"modal fade\" id=\"masterDetailsModal\" tabindex=\"-1\">' +\n\t\t\t'<div class=\"modal-dialog modal-lg\">' +\n\t\t\t'<div class=\"modal-content\">' +\n\t\t\t'<div class=\"modal-header\">' +\n\t\t\t'<h5 class=\"modal-title\"><i class=\"fas fa-crown me-2\"></i>Master Details: ' + address + '</h5>' +\n\t\t\t'<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-body\">' +\n\t\t\t'<div class=\"row\">' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-info-circle me-1\"></i>Basic Information</h6>' +\n\t\t\t'<table class=\"table table-sm\">' +\n\t\t\t'<tr><td><strong>Address:</strong></td><td>' + address + '</td></tr>' +\n\t\t\t'<tr><td><strong>Role:</strong></td><td>' + \n\t\t\t(isLeader ? '<span class=\"badge bg-warning text-dark\"><i class=\"fas fa-star me-1\"></i>Leader</span>' : \n\t\t\t'<span class=\"badge bg-secondary\">Follower</span>') + '</td></tr>' +\n\t\t\t'<tr><td><strong>Suffrage:</strong></td><td>' + (suffrage || 'N/A') + '</td></tr>' +\n\t\t\t'<tr><td><strong>Status:</strong></td><td><span class=\"badge bg-success\">Active</span></td></tr>' +\n\t\t\t'</table>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-link me-1\"></i>Quick Actions</h6>' +\n\t\t\t'<div class=\"d-grid gap-2\">' +\n\t\t\t'<a href=\"http://' + address + '\" target=\"_blank\" class=\"btn btn-outline-primary\">' +\n\t\t\t'<i class=\"fas fa-external-link-alt me-1\"></i>Open Master UI' +\n\t\t\t'</a>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-footer\">' +\n\t\t\t'<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Remove existing modal if present\n\t\tconst existingModal = document.getElementById('masterDetailsModal');\n\t\tif (existingModal) {\n\t\t\texistingModal.remove();\n\t\t}\n\t\t\n\t\t// Add modal to body and show\n\t\tdocument.body.insertAdjacentHTML('beforeend', modalHtml);\n\t\tconst modal = new bootstrap.Modal(document.getElementById('masterDetailsModal'));\n\t\tmodal.show();\n\t\t\n\t\t// Remove modal when hidden\n\t\tdocument.getElementById('masterDetailsModal').addEventListener('hidden.bs.modal', function() {\n\t\t\tthis.remove();\n\t\t});\n\t}\n\t\n\tfunction exportMasters() {\n\t\t// Simple CSV export of masters list\n\t\tconst rows = Array.from(document.querySelectorAll('#mastersTable tbody tr')).map(row => {\n\t\t\tconst cells = row.querySelectorAll('td');\n\t\t\tif (cells.length > 1) {\n\t\t\t\treturn {\n\t\t\t\t\taddress: cells[0].textContent.trim(),\n\t\t\t\t\trole: cells[1].textContent.trim(),\n\t\t\t\t\tsuffrage: cells[2].textContent.trim()\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t}).filter(row => row !== null);\n\t\t\n\t\tconst csvContent = \"data:text/csv;charset=utf-8,\" + \n\t\t\t\"Address,Role,Suffrage\\n\" +\n\t\t\trows.map(r => '\"' + r.address + '\",\"' + r.role + '\",\"' + r.suffrage + '\"').join(\"\\n\");\n\t\t\n\t\tconst encodedUri = encodeURI(csvContent);\n\t\tconst link = document.createElement(\"a\");\n\t\tlink.setAttribute(\"href\", encodedUri);\n\t\tlink.setAttribute(\"download\", \"masters.csv\");\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/cluster_volume_servers.templ b/weed/admin/view/app/cluster_volume_servers.templ
index 20c661d40..f6b737a57 100644
--- a/weed/admin/view/app/cluster_volume_servers.templ
+++ b/weed/admin/view/app/cluster_volume_servers.templ
@@ -148,16 +148,22 @@ templ ClusterVolumeServers(data dash.ClusterVolumeServersData) {
</div>
</td>
<td>
- <div class="btn-group btn-group-sm">
- <button type="button" class="btn btn-outline-primary btn-sm"
- title="View Details">
- <i class="fas fa-eye"></i>
- </button>
- <button type="button" class="btn btn-outline-secondary btn-sm"
- title="Manage">
- <i class="fas fa-cog"></i>
- </button>
- </div>
+ <button type="button"
+ class="btn btn-outline-primary btn-sm"
+ title="View Details"
+ data-action="view-details"
+ data-id={host.ID}
+ data-address={host.Address}
+ data-public-url={host.PublicURL}
+ data-datacenter={host.DataCenter}
+ data-rack={host.Rack}
+ data-volumes={fmt.Sprintf("%d", host.Volumes)}
+ data-max-volumes={fmt.Sprintf("%d", host.MaxVolumes)}
+ data-disk-usage={fmt.Sprintf("%d", host.DiskUsage)}
+ data-disk-capacity={fmt.Sprintf("%d", host.DiskCapacity)}
+ data-last-heartbeat={host.LastHeartbeat.Format("2006-01-02 15:04:05")}>
+ <i class="fas fa-eye"></i>
+ </button>
</td>
</tr>
}
@@ -184,6 +190,161 @@ templ ClusterVolumeServers(data dash.ClusterVolumeServersData) {
</div>
</div>
</div>
+
+ <!-- JavaScript for cluster volume servers functionality -->
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+ // Handle volume server action buttons
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const action = button.getAttribute('data-action');
+
+ switch(action) {
+ case 'view-details':
+ const serverData = {
+ id: button.getAttribute('data-id'),
+ address: button.getAttribute('data-address'),
+ publicUrl: button.getAttribute('data-public-url'),
+ datacenter: button.getAttribute('data-datacenter'),
+ rack: button.getAttribute('data-rack'),
+ volumes: parseInt(button.getAttribute('data-volumes')),
+ maxVolumes: parseInt(button.getAttribute('data-max-volumes')),
+ diskUsage: parseInt(button.getAttribute('data-disk-usage')),
+ diskCapacity: parseInt(button.getAttribute('data-disk-capacity')),
+ lastHeartbeat: button.getAttribute('data-last-heartbeat')
+ };
+ showVolumeServerDetails(serverData);
+ break;
+ }
+ });
+ });
+
+ function showVolumeServerDetails(server) {
+ const volumePercent = server.maxVolumes > 0 ? Math.round((server.volumes / server.maxVolumes) * 100) : 0;
+ const diskPercent = server.diskCapacity > 0 ? Math.round((server.diskUsage / server.diskCapacity) * 100) : 0;
+
+ const modalHtml = '<div class="modal fade" id="volumeServerDetailsModal" tabindex="-1">' +
+ '<div class="modal-dialog modal-lg">' +
+ '<div class="modal-content">' +
+ '<div class="modal-header">' +
+ '<h5 class="modal-title"><i class="fas fa-server me-2"></i>Volume Server Details: ' + server.address + '</h5>' +
+ '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
+ '</div>' +
+ '<div class="modal-body">' +
+ '<div class="row">' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-info-circle me-1"></i>Basic Information</h6>' +
+ '<table class="table table-sm">' +
+ '<tr><td><strong>Server ID:</strong></td><td><code>' + server.id + '</code></td></tr>' +
+ '<tr><td><strong>Address:</strong></td><td>' + server.address + '</td></tr>' +
+ '<tr><td><strong>Public URL:</strong></td><td>' + server.publicUrl + '</td></tr>' +
+ '<tr><td><strong>Data Center:</strong></td><td><span class="badge bg-light text-dark">' + server.datacenter + '</span></td></tr>' +
+ '<tr><td><strong>Rack:</strong></td><td><span class="badge bg-light text-dark">' + server.rack + '</span></td></tr>' +
+ '<tr><td><strong>Last Heartbeat:</strong></td><td>' + server.lastHeartbeat + '</td></tr>' +
+ '</table>' +
+ '</div>' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-chart-bar me-1"></i>Usage Statistics</h6>' +
+ '<table class="table table-sm">' +
+ '<tr><td><strong>Volumes:</strong></td><td>' +
+ '<div class="d-flex align-items-center">' +
+ '<div class="progress me-2" style="width: 100px; height: 20px;">' +
+ '<div class="progress-bar" role="progressbar" style="width: ' + volumePercent + '%"></div>' +
+ '</div>' +
+ '<span>' + server.volumes + '/' + server.maxVolumes + ' (' + volumePercent + '%)</span>' +
+ '</div>' +
+ '</td></tr>' +
+ '<tr><td><strong>Disk Usage:</strong></td><td>' +
+ '<div class="d-flex align-items-center">' +
+ '<div class="progress me-2" style="width: 100px; height: 20px;">' +
+ '<div class="progress-bar" role="progressbar" style="width: ' + diskPercent + '%"></div>' +
+ '</div>' +
+ '<span>' + formatBytes(server.diskUsage) + '/' + formatBytes(server.diskCapacity) + ' (' + diskPercent + '%)</span>' +
+ '</div>' +
+ '</td></tr>' +
+ '<tr><td><strong>Available Space:</strong></td><td>' + formatBytes(server.diskCapacity - server.diskUsage) + '</td></tr>' +
+ '</table>' +
+ '</div>' +
+ '</div>' +
+ '<div class="row mt-3">' +
+ '<div class="col-12">' +
+ '<h6 class="text-primary"><i class="fas fa-link me-1"></i>Quick Actions</h6>' +
+ '<div class="d-grid gap-2 d-md-flex">' +
+ '<a href="http://' + server.publicUrl + '/ui/index.html" target="_blank" class="btn btn-outline-primary">' +
+ '<i class="fas fa-external-link-alt me-1"></i>Open Volume Server UI' +
+ '</a>' +
+ '<a href="/cluster/volumes?server=' + encodeURIComponent(server.address) + '" class="btn btn-outline-info">' +
+ '<i class="fas fa-database me-1"></i>View Volumes' +
+ '</a>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '<div class="modal-footer">' +
+ '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>';
+
+ // Remove existing modal if present
+ const existingModal = document.getElementById('volumeServerDetailsModal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // Add modal to body and show
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
+ const modal = new bootstrap.Modal(document.getElementById('volumeServerDetailsModal'));
+ modal.show();
+
+ // Remove modal when hidden
+ document.getElementById('volumeServerDetailsModal').addEventListener('hidden.bs.modal', function() {
+ this.remove();
+ });
+ }
+
+ function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ function exportVolumeServers() {
+ // Simple CSV export of volume servers list
+ const rows = Array.from(document.querySelectorAll('#hostsTable tbody tr')).map(row => {
+ const cells = row.querySelectorAll('td');
+ if (cells.length > 1) {
+ return {
+ id: cells[0].textContent.trim(),
+ address: cells[1].textContent.trim(),
+ datacenter: cells[2].textContent.trim(),
+ rack: cells[3].textContent.trim(),
+ volumes: cells[4].textContent.trim(),
+ capacity: cells[5].textContent.trim(),
+ usage: cells[6].textContent.trim()
+ };
+ }
+ return null;
+ }).filter(row => row !== null);
+
+ const csvContent = "data:text/csv;charset=utf-8," +
+ "Server ID,Address,Data Center,Rack,Volumes,Capacity,Usage\n" +
+ rows.map(r => '"' + r.id + '","' + r.address + '","' + r.datacenter + '","' + r.rack + '","' + r.volumes + '","' + r.capacity + '","' + r.usage + '"').join("\n");
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", "volume_servers.csv");
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ </script>
}
\ No newline at end of file
diff --git a/weed/admin/view/app/cluster_volume_servers_templ.go b/weed/admin/view/app/cluster_volume_servers_templ.go
index 1bd439974..094774c7a 100644
--- a/weed/admin/view/app/cluster_volume_servers_templ.go
+++ b/weed/admin/view/app/cluster_volume_servers_templ.go
@@ -213,35 +213,165 @@ func ClusterVolumeServers(data dash.ClusterVolumeServersData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Manage\"><i class=\"fas fa-cog\"></i></button></div></td></tr>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></td><td><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\" data-action=\"view-details\" data-id=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(host.ID)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 155, Col: 68}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" data-address=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(host.Address)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 156, Col: 78}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-public-url=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(host.PublicURL)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 157, Col: 83}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-datacenter=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(host.DataCenter)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 158, Col: 84}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" data-rack=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(host.Rack)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 159, Col: 72}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-volumes=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var20 string
+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", host.Volumes))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 160, Col: 97}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-max-volumes=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var21 string
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", host.MaxVolumes))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 161, Col: 104}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-disk-usage=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var22 string
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", host.DiskUsage))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 162, Col: 102}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" data-disk-capacity=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", host.DiskCapacity))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 163, Col: 108}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-last-heartbeat=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var24 string
+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(host.LastHeartbeat.Format("2006-01-02 15:04:05"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 164, Col: 121}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"><i class=\"fas fa-eye\"></i></button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</tbody></table></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"text-center py-5\"><i class=\"fas fa-server fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Volume Servers Found</h5><p class=\"text-muted\">No volume servers are currently available in the cluster.</p></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"text-center py-5\"><i class=\"fas fa-server fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Volume Servers Found</h5><p class=\"text-muted\">No volume servers are currently available in the cluster.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var15 string
- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
+ var templ_7745c5c3_Var25 string
+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 182, Col: 81}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 188, Col: 81}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</small></div></div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</small></div></div></div><!-- JavaScript for cluster volume servers functionality --><script>\n document.addEventListener('DOMContentLoaded', function() {\n // Handle volume server action buttons\n document.addEventListener('click', function(e) {\n const button = e.target.closest('[data-action]');\n if (!button) return;\n \n const action = button.getAttribute('data-action');\n \n switch(action) {\n case 'view-details':\n const serverData = {\n id: button.getAttribute('data-id'),\n address: button.getAttribute('data-address'),\n publicUrl: button.getAttribute('data-public-url'),\n datacenter: button.getAttribute('data-datacenter'),\n rack: button.getAttribute('data-rack'),\n volumes: parseInt(button.getAttribute('data-volumes')),\n maxVolumes: parseInt(button.getAttribute('data-max-volumes')),\n diskUsage: parseInt(button.getAttribute('data-disk-usage')),\n diskCapacity: parseInt(button.getAttribute('data-disk-capacity')),\n lastHeartbeat: button.getAttribute('data-last-heartbeat')\n };\n showVolumeServerDetails(serverData);\n break;\n }\n });\n });\n \n function showVolumeServerDetails(server) {\n const volumePercent = server.maxVolumes > 0 ? Math.round((server.volumes / server.maxVolumes) * 100) : 0;\n const diskPercent = server.diskCapacity > 0 ? Math.round((server.diskUsage / server.diskCapacity) * 100) : 0;\n \n const modalHtml = '<div class=\"modal fade\" id=\"volumeServerDetailsModal\" tabindex=\"-1\">' +\n '<div class=\"modal-dialog modal-lg\">' +\n '<div class=\"modal-content\">' +\n '<div class=\"modal-header\">' +\n '<h5 class=\"modal-title\"><i class=\"fas fa-server me-2\"></i>Volume Server Details: ' + server.address + '</h5>' +\n '<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n '</div>' +\n '<div class=\"modal-body\">' +\n '<div class=\"row\">' +\n '<div class=\"col-md-6\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-info-circle me-1\"></i>Basic Information</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr><td><strong>Server ID:</strong></td><td><code>' + server.id + '</code></td></tr>' +\n '<tr><td><strong>Address:</strong></td><td>' + server.address + '</td></tr>' +\n '<tr><td><strong>Public URL:</strong></td><td>' + server.publicUrl + '</td></tr>' +\n '<tr><td><strong>Data Center:</strong></td><td><span class=\"badge bg-light text-dark\">' + server.datacenter + '</span></td></tr>' +\n '<tr><td><strong>Rack:</strong></td><td><span class=\"badge bg-light text-dark\">' + server.rack + '</span></td></tr>' +\n '<tr><td><strong>Last Heartbeat:</strong></td><td>' + server.lastHeartbeat + '</td></tr>' +\n '</table>' +\n '</div>' +\n '<div class=\"col-md-6\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-chart-bar me-1\"></i>Usage Statistics</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr><td><strong>Volumes:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<div class=\"progress me-2\" style=\"width: 100px; height: 20px;\">' +\n '<div class=\"progress-bar\" role=\"progressbar\" style=\"width: ' + volumePercent + '%\"></div>' +\n '</div>' +\n '<span>' + server.volumes + '/' + server.maxVolumes + ' (' + volumePercent + '%)</span>' +\n '</div>' +\n '</td></tr>' +\n '<tr><td><strong>Disk Usage:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<div class=\"progress me-2\" style=\"width: 100px; height: 20px;\">' +\n '<div class=\"progress-bar\" role=\"progressbar\" style=\"width: ' + diskPercent + '%\"></div>' +\n '</div>' +\n '<span>' + formatBytes(server.diskUsage) + '/' + formatBytes(server.diskCapacity) + ' (' + diskPercent + '%)</span>' +\n '</div>' +\n '</td></tr>' +\n '<tr><td><strong>Available Space:</strong></td><td>' + formatBytes(server.diskCapacity - server.diskUsage) + '</td></tr>' +\n '</table>' +\n '</div>' +\n '</div>' +\n '<div class=\"row mt-3\">' +\n '<div class=\"col-12\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-link me-1\"></i>Quick Actions</h6>' +\n '<div class=\"d-grid gap-2 d-md-flex\">' +\n '<a href=\"http://' + server.publicUrl + '/ui/index.html\" target=\"_blank\" class=\"btn btn-outline-primary\">' +\n '<i class=\"fas fa-external-link-alt me-1\"></i>Open Volume Server UI' +\n '</a>' +\n '<a href=\"/cluster/volumes?server=' + encodeURIComponent(server.address) + '\" class=\"btn btn-outline-info\">' +\n '<i class=\"fas fa-database me-1\"></i>View Volumes' +\n '</a>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '<div class=\"modal-footer\">' +\n '<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '</div>';\n \n // Remove existing modal if present\n const existingModal = document.getElementById('volumeServerDetailsModal');\n if (existingModal) {\n existingModal.remove();\n }\n \n // Add modal to body and show\n document.body.insertAdjacentHTML('beforeend', modalHtml);\n const modal = new bootstrap.Modal(document.getElementById('volumeServerDetailsModal'));\n modal.show();\n \n // Remove modal when hidden\n document.getElementById('volumeServerDetailsModal').addEventListener('hidden.bs.modal', function() {\n this.remove();\n });\n }\n \n function formatBytes(bytes) {\n if (bytes === 0) return '0 Bytes';\n const k = 1024;\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n }\n \n function exportVolumeServers() {\n // Simple CSV export of volume servers list\n const rows = Array.from(document.querySelectorAll('#hostsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n id: cells[0].textContent.trim(),\n address: cells[1].textContent.trim(),\n datacenter: cells[2].textContent.trim(),\n rack: cells[3].textContent.trim(),\n volumes: cells[4].textContent.trim(),\n capacity: cells[5].textContent.trim(),\n usage: cells[6].textContent.trim()\n };\n }\n return null;\n }).filter(row => row !== null);\n \n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Server ID,Address,Data Center,Rack,Volumes,Capacity,Usage\\n\" +\n rows.map(r => '\"' + r.id + '\",\"' + r.address + '\",\"' + r.datacenter + '\",\"' + r.rack + '\",\"' + r.volumes + '\",\"' + r.capacity + '\",\"' + r.usage + '\"').join(\"\\n\");\n \n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"volume_servers.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/file_browser.templ b/weed/admin/view/app/file_browser.templ
index a1e00555f..83db7df0f 100644
--- a/weed/admin/view/app/file_browser.templ
+++ b/weed/admin/view/app/file_browser.templ
@@ -228,7 +228,7 @@ templ FileBrowser(data dash.FileBrowserData) {
}
</td>
<td>
- <code class="small">{ entry.Mode }</code>
+ <code class="small permissions-display" data-mode={ entry.Mode } data-is-directory={ fmt.Sprintf("%t", entry.IsDirectory) }>{ entry.Mode }</code>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
@@ -356,6 +356,380 @@ templ FileBrowser(data dash.FileBrowserData) {
</div>
</div>
</div>
+
+ <!-- JavaScript for file browser functionality -->
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+ // Format permissions in the main table
+ document.querySelectorAll('.permissions-display').forEach(element => {
+ const mode = element.getAttribute('data-mode');
+ const isDirectory = element.getAttribute('data-is-directory') === 'true';
+ if (mode) {
+ element.textContent = formatPermissions(mode, isDirectory);
+ }
+ });
+
+ // Handle file browser action buttons (download, view, properties, delete)
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const action = button.getAttribute('data-action');
+ const path = button.getAttribute('data-path');
+
+ if (!path) return;
+
+ switch(action) {
+ case 'download':
+ downloadFile(path);
+ break;
+ case 'view':
+ viewFile(path);
+ break;
+ case 'properties':
+ showFileProperties(path);
+ break;
+ case 'delete':
+ if (confirm('Are you sure you want to delete "' + path + '"?')) {
+ deleteFile(path);
+ }
+ break;
+ }
+ });
+
+ // Initialize file manager event handlers from admin.js
+ if (typeof setupFileManagerEventHandlers === 'function') {
+ setupFileManagerEventHandlers();
+ }
+ });
+
+ // File browser specific functions
+ function downloadFile(path) {
+ // Open download URL in new tab
+ window.open('/api/files/download?path=' + encodeURIComponent(path), '_blank');
+ }
+
+ function viewFile(path) {
+ // Open file viewer in new tab
+ window.open('/api/files/view?path=' + encodeURIComponent(path), '_blank');
+ }
+
+ function showFileProperties(path) {
+ // Fetch file properties and show in modal
+ fetch('/api/files/properties?path=' + encodeURIComponent(path))
+ .then(response => response.json())
+ .then(data => {
+ if (data.error) {
+ alert('Error loading file properties: ' + data.error);
+ } else {
+ displayFileProperties(data);
+ }
+ })
+ .catch(error => {
+ console.error('Error fetching file properties:', error);
+ alert('Error loading file properties: ' + error.message);
+ });
+ }
+
+ function displayFileProperties(data) {
+ // Create a comprehensive modal for file properties
+ const modalHtml = '<div class="modal fade" id="filePropertiesModal" tabindex="-1">' +
+ '<div class="modal-dialog modal-lg">' +
+ '<div class="modal-content">' +
+ '<div class="modal-header">' +
+ '<h5 class="modal-title"><i class="fas fa-info-circle me-2"></i>Properties: ' + (data.name || 'Unknown') + '</h5>' +
+ '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
+ '</div>' +
+ '<div class="modal-body">' +
+ createFilePropertiesContent(data) +
+ '</div>' +
+ '<div class="modal-footer">' +
+ '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>';
+
+ // Remove existing modal if present
+ const existingModal = document.getElementById('filePropertiesModal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // Add modal to body and show
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
+ const modal = new bootstrap.Modal(document.getElementById('filePropertiesModal'));
+ modal.show();
+
+ // Remove modal when hidden
+ document.getElementById('filePropertiesModal').addEventListener('hidden.bs.modal', function() {
+ this.remove();
+ });
+ }
+
+ function createFilePropertiesContent(data) {
+ let html = '<div class="row">' +
+ '<div class="col-12">' +
+ '<h6 class="text-primary"><i class="fas fa-file me-1"></i>Basic Information</h6>' +
+ '<table class="table table-sm">' +
+ '<tr><td style="width: 120px;"><strong>Name:</strong></td><td>' + (data.name || 'N/A') + '</td></tr>' +
+ '<tr><td><strong>Full Path:</strong></td><td><code class="text-break">' + (data.full_path || 'N/A') + '</code></td></tr>' +
+ '<tr><td><strong>Type:</strong></td><td>' + (data.is_directory ? 'Directory' : 'File') + '</td></tr>';
+
+ if (!data.is_directory) {
+ html += '<tr><td><strong>Size:</strong></td><td>' + (data.size_formatted || (data.size ? formatBytes(data.size) : 'N/A')) + '</td></tr>' +
+ '<tr><td><strong>MIME Type:</strong></td><td>' + (data.mime_type || 'N/A') + '</td></tr>';
+ }
+
+ html += '</table>' +
+ '</div>' +
+ '</div>' +
+ '<div class="row">' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-clock me-1"></i>Timestamps</h6>' +
+ '<table class="table table-sm">';
+
+ if (data.modified_time) {
+ html += '<tr><td><strong>Modified:</strong></td><td>' + data.modified_time + '</td></tr>';
+ }
+ if (data.created_time) {
+ html += '<tr><td><strong>Created:</strong></td><td>' + data.created_time + '</td></tr>';
+ }
+
+ html += '</table>' +
+ '</div>' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-shield-alt me-1"></i>Permissions</h6>' +
+ '<table class="table table-sm">';
+
+ if (data.file_mode) {
+ const rwxPermissions = formatPermissions(data.file_mode, data.is_directory);
+ html += '<tr><td><strong>Permissions:</strong></td><td><code>' + rwxPermissions + '</code></td></tr>';
+ }
+ if (data.uid !== undefined) {
+ html += '<tr><td><strong>User ID:</strong></td><td>' + data.uid + '</td></tr>';
+ }
+ if (data.gid !== undefined) {
+ html += '<tr><td><strong>Group ID:</strong></td><td>' + data.gid + '</td></tr>';
+ }
+
+ html += '</table>' +
+ '</div>' +
+ '</div>';
+
+ // Add advanced info
+ html += '<div class="row">' +
+ '<div class="col-12">' +
+ '<h6 class="text-primary"><i class="fas fa-cog me-1"></i>Advanced</h6>' +
+ '<table class="table table-sm">';
+
+ if (data.chunk_count) {
+ html += '<tr><td style="width: 120px;"><strong>Chunks:</strong></td><td>' + data.chunk_count + '</td></tr>';
+ }
+ if (data.ttl_formatted) {
+ html += '<tr><td><strong>TTL:</strong></td><td>' + data.ttl_formatted + '</td></tr>';
+ }
+
+ html += '</table>' +
+ '</div>' +
+ '</div>';
+
+ // Add chunk details if available (show top 5)
+ if (data.chunks && data.chunks.length > 0) {
+ const chunksToShow = data.chunks.slice(0, 5);
+ html += '<div class="row mt-3">' +
+ '<div class="col-12">' +
+ '<h6 class="text-primary"><i class="fas fa-puzzle-piece me-1"></i>Chunk Details' +
+ (data.chunk_count > 5 ? ' (Top 5 of ' + data.chunk_count + ')' : ' (' + data.chunk_count + ')') +
+ '</h6>' +
+ '<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">' +
+ '<table class="table table-sm table-striped">' +
+ '<thead>' +
+ '<tr>' +
+ '<th>File ID</th>' +
+ '<th>Offset</th>' +
+ '<th>Size</th>' +
+ '<th>ETag</th>' +
+ '</tr>' +
+ '</thead>' +
+ '<tbody>';
+
+ chunksToShow.forEach(chunk => {
+ html += '<tr>' +
+ '<td><code class="small">' + (chunk.file_id || 'N/A') + '</code></td>' +
+ '<td>' + formatBytes(chunk.offset || 0) + '</td>' +
+ '<td>' + formatBytes(chunk.size || 0) + '</td>' +
+ '<td><code class="small">' + (chunk.e_tag || 'N/A') + '</code></td>' +
+ '</tr>';
+ });
+
+ html += '</tbody>' +
+ '</table>' +
+ '</div>' +
+ '</div>' +
+ '</div>';
+ }
+
+ // Add extended attributes if present
+ if (data.extended && Object.keys(data.extended).length > 0) {
+ html += '<div class="row">' +
+ '<div class="col-12">' +
+ '<h6 class="text-primary"><i class="fas fa-tags me-1"></i>Extended Attributes</h6>' +
+ '<table class="table table-sm">';
+
+ for (const [key, value] of Object.entries(data.extended)) {
+ html += '<tr><td><strong>' + key + ':</strong></td><td>' + value + '</td></tr>';
+ }
+
+ html += '</table>' +
+ '</div>' +
+ '</div>';
+ }
+
+ return html;
+ }
+
+ function uploadFile() {
+ const modal = new bootstrap.Modal(document.getElementById('uploadFileModal'));
+ modal.show();
+ }
+
+ function toggleSelectAll() {
+ const selectAllCheckbox = document.getElementById('selectAll');
+ const checkboxes = document.querySelectorAll('.file-checkbox');
+
+ checkboxes.forEach(checkbox => {
+ checkbox.checked = selectAllCheckbox.checked;
+ });
+
+ updateDeleteSelectedButton();
+ }
+
+ function updateDeleteSelectedButton() {
+ const checkboxes = document.querySelectorAll('.file-checkbox:checked');
+ const deleteBtn = document.getElementById('deleteSelectedBtn');
+
+ if (checkboxes.length > 0) {
+ deleteBtn.style.display = 'inline-block';
+ } else {
+ deleteBtn.style.display = 'none';
+ }
+ }
+
+ // Helper function to format bytes
+ function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ // Helper function to format permissions in rwxrwxrwx format
+ function formatPermissions(mode, isDirectory) {
+ // Check if mode is already in rwxrwxrwx format (e.g., "drwxr-xr-x" or "-rw-r--r--")
+ if (mode && (mode.startsWith('d') || mode.startsWith('-') || mode.startsWith('l')) && mode.length === 10) {
+ return mode; // Already formatted
+ }
+
+ // Convert to number - could be octal string or decimal
+ let permissions;
+ if (typeof mode === 'string') {
+ // Try parsing as octal first, then decimal
+ if (mode.startsWith('0') && mode.length <= 4) {
+ permissions = parseInt(mode, 8);
+ } else {
+ permissions = parseInt(mode, 10);
+ }
+ } else {
+ permissions = parseInt(mode, 10);
+ }
+
+ if (isNaN(permissions)) {
+ return isDirectory ? 'drwxr-xr-x' : '-rw-r--r--'; // Default fallback
+ }
+
+ // Handle Go's os.ModeDir conversion
+ // Go's os.ModeDir is 0x80000000 (2147483648), but Unix S_IFDIR is 0o40000 (16384)
+ let fileType = '-';
+
+ // Check for Go's os.ModeDir flag
+ if (permissions & 0x80000000) {
+ fileType = 'd';
+ }
+ // Check for standard Unix file type bits
+ else if ((permissions & 0xF000) === 0x4000) { // S_IFDIR (0o40000)
+ fileType = 'd';
+ } else if ((permissions & 0xF000) === 0x8000) { // S_IFREG (0o100000)
+ fileType = '-';
+ } else if ((permissions & 0xF000) === 0xA000) { // S_IFLNK (0o120000)
+ fileType = 'l';
+ } else if ((permissions & 0xF000) === 0x2000) { // S_IFCHR (0o020000)
+ fileType = 'c';
+ } else if ((permissions & 0xF000) === 0x6000) { // S_IFBLK (0o060000)
+ fileType = 'b';
+ } else if ((permissions & 0xF000) === 0x1000) { // S_IFIFO (0o010000)
+ fileType = 'p';
+ } else if ((permissions & 0xF000) === 0xC000) { // S_IFSOCK (0o140000)
+ fileType = 's';
+ }
+ // Fallback to isDirectory parameter if file type detection fails
+ else if (isDirectory) {
+ fileType = 'd';
+ }
+
+ // Permission bits (always use the lower 12 bits for permissions)
+ const owner = (permissions >> 6) & 7;
+ const group = (permissions >> 3) & 7;
+ const others = permissions & 7;
+
+ // Convert number to rwx format
+ function numToRwx(num) {
+ const r = (num & 4) ? 'r' : '-';
+ const w = (num & 2) ? 'w' : '-';
+ const x = (num & 1) ? 'x' : '-';
+ return r + w + x;
+ }
+
+ return fileType + numToRwx(owner) + numToRwx(group) + numToRwx(others);
+ }
+
+ function exportFileList() {
+ // Simple CSV export of file list
+ const rows = Array.from(document.querySelectorAll('#fileTable tbody tr')).map(row => {
+ const cells = row.querySelectorAll('td');
+ if (cells.length > 1) {
+ return {
+ name: cells[1].textContent.trim(),
+ size: cells[2].textContent.trim(),
+ type: cells[3].textContent.trim(),
+ modified: cells[4].textContent.trim(),
+ permissions: cells[5].textContent.trim()
+ };
+ }
+ return null;
+ }).filter(row => row !== null);
+
+ const csvContent = "data:text/csv;charset=utf-8," +
+ "Name,Size,Type,Modified,Permissions\n" +
+ rows.map(r => '"' + r.name + '","' + r.size + '","' + r.type + '","' + r.modified + '","' + r.permissions + '"').join("\n");
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", "files.csv");
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+
+ // Handle file checkbox changes
+ document.addEventListener('change', function(e) {
+ if (e.target.classList.contains('file-checkbox')) {
+ updateDeleteSelectedButton();
+ }
+ });
+ </script>
}
func countDirectories(entries []dash.FileEntry) int {
diff --git a/weed/admin/view/app/file_browser_templ.go b/weed/admin/view/app/file_browser_templ.go
index c4367e82d..ca1db51b2 100644
--- a/weed/admin/view/app/file_browser_templ.go
+++ b/weed/admin/view/app/file_browser_templ.go
@@ -392,136 +392,162 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</td><td><code class=\"small\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</td><td><code class=\"small permissions-display\" data-mode=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Mode)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 42}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</code></td><td><div class=\"btn-group btn-group-sm\" role=\"group\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" data-is-directory=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var22 string
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", entry.IsDirectory))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 131}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Mode)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 146}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</code></td><td><div class=\"btn-group btn-group-sm\" role=\"group\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !entry.IsDirectory {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"Download\" data-action=\"download\" data-path=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"Download\" data-action=\"download\" data-path=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var22 string
- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
+ var templ_7745c5c3_Var24 string
+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 236, Col: 139}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\"><i class=\"fas fa-download\"></i></button> <button type=\"button\" class=\"btn btn-outline-info btn-sm\" title=\"View\" data-action=\"view\" data-path=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\"><i class=\"fas fa-download\"></i></button> <button type=\"button\" class=\"btn btn-outline-info btn-sm\" title=\"View\" data-action=\"view\" data-path=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var23 string
- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
+ var templ_7745c5c3_Var25 string
+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 239, Col: 128}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"><i class=\"fas fa-eye\"></i></button> ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><i class=\"fas fa-eye\"></i></button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Properties\" data-action=\"properties\" data-path=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Properties\" data-action=\"properties\" data-path=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var24 string
- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
+ var templ_7745c5c3_Var26 string
+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 243, Col: 144}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><i class=\"fas fa-info-circle\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger btn-sm\" title=\"Delete\" data-action=\"delete\" data-path=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"><i class=\"fas fa-info-circle\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger btn-sm\" title=\"Delete\" data-action=\"delete\" data-path=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var25 string
- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
+ var templ_7745c5c3_Var27 string
+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 246, Col: 133}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</tbody></table></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"text-center py-5\"><i class=\"fas fa-folder-open fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">Empty Directory</h5><p class=\"text-muted\">This directory contains no files or subdirectories.</p></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"text-center py-5\"><i class=\"fas fa-folder-open fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">Empty Directory</h5><p class=\"text-muted\">This directory contains no files or subdirectories.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var26 string
- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
+ var templ_7745c5c3_Var28 string
+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 271, Col: 66}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</small></div></div><!-- Create Folder Modal --><div class=\"modal fade\" id=\"createFolderModal\" tabindex=\"-1\" aria-labelledby=\"createFolderModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createFolderModalLabel\"><i class=\"fas fa-folder-plus me-2\"></i>Create New Folder</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"createFolderForm\"><div class=\"mb-3\"><label for=\"folderName\" class=\"form-label\">Folder Name</label> <input type=\"text\" class=\"form-control\" id=\"folderName\" name=\"folderName\" required placeholder=\"Enter folder name\" maxlength=\"255\"><div class=\"form-text\">Folder names cannot contain / or \\ characters.</div></div><input type=\"hidden\" id=\"currentPath\" name=\"currentPath\" value=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</small></div></div><!-- Create Folder Modal --><div class=\"modal fade\" id=\"createFolderModal\" tabindex=\"-1\" aria-labelledby=\"createFolderModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createFolderModalLabel\"><i class=\"fas fa-folder-plus me-2\"></i>Create New Folder</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"createFolderForm\"><div class=\"mb-3\"><label for=\"folderName\" class=\"form-label\">Folder Name</label> <input type=\"text\" class=\"form-control\" id=\"folderName\" name=\"folderName\" required placeholder=\"Enter folder name\" maxlength=\"255\"><div class=\"form-text\">Folder names cannot contain / or \\ characters.</div></div><input type=\"hidden\" id=\"currentPath\" name=\"currentPath\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var27 string
- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
+ var templ_7745c5c3_Var29 string
+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 296, Col: 87}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\"></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitCreateFolder()\"><i class=\"fas fa-folder-plus me-1\"></i>Create Folder</button></div></div></div></div><!-- Upload File Modal --><div class=\"modal fade\" id=\"uploadFileModal\" tabindex=\"-1\" aria-labelledby=\"uploadFileModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"uploadFileModalLabel\"><i class=\"fas fa-upload me-2\"></i>Upload Files</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"uploadFileForm\" enctype=\"multipart/form-data\"><div class=\"mb-3\"><label for=\"fileInput\" class=\"form-label\">Select Files</label> <input type=\"file\" class=\"form-control\" id=\"fileInput\" name=\"files\" multiple required><div class=\"form-text\">Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.</div></div><input type=\"hidden\" id=\"uploadPath\" name=\"path\" value=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitCreateFolder()\"><i class=\"fas fa-folder-plus me-1\"></i>Create Folder</button></div></div></div></div><!-- Upload File Modal --><div class=\"modal fade\" id=\"uploadFileModal\" tabindex=\"-1\" aria-labelledby=\"uploadFileModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"uploadFileModalLabel\"><i class=\"fas fa-upload me-2\"></i>Upload Files</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"uploadFileForm\" enctype=\"multipart/form-data\"><div class=\"mb-3\"><label for=\"fileInput\" class=\"form-label\">Select Files</label> <input type=\"file\" class=\"form-control\" id=\"fileInput\" name=\"files\" multiple required><div class=\"form-text\">Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.</div></div><input type=\"hidden\" id=\"uploadPath\" name=\"path\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var28 string
- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
+ var templ_7745c5c3_Var30 string
+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 328, Col: 79}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\"><!-- File List Preview --><div id=\"fileListPreview\" class=\"mb-3\" style=\"display: none;\"><label class=\"form-label\">Selected Files:</label><div id=\"selectedFilesList\" class=\"border rounded p-2 bg-light\"><!-- Files will be listed here --></div></div><!-- Upload Progress --><div class=\"mb-3\" id=\"uploadProgress\" style=\"display: none;\"><label class=\"form-label\">Upload Progress:</label><div class=\"progress mb-2\"><div class=\"progress-bar progress-bar-striped progress-bar-animated\" role=\"progressbar\" style=\"width: 0%\" aria-valuenow=\"0\" aria-valuemin=\"0\" aria-valuemax=\"100\">0%</div></div><div id=\"uploadStatus\" class=\"small text-muted\">Preparing upload...</div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitUploadFile()\"><i class=\"fas fa-upload me-1\"></i>Upload Files</button></div></div></div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"><!-- File List Preview --><div id=\"fileListPreview\" class=\"mb-3\" style=\"display: none;\"><label class=\"form-label\">Selected Files:</label><div id=\"selectedFilesList\" class=\"border rounded p-2 bg-light\"><!-- Files will be listed here --></div></div><!-- Upload Progress --><div class=\"mb-3\" id=\"uploadProgress\" style=\"display: none;\"><label class=\"form-label\">Upload Progress:</label><div class=\"progress mb-2\"><div class=\"progress-bar progress-bar-striped progress-bar-animated\" role=\"progressbar\" style=\"width: 0%\" aria-valuenow=\"0\" aria-valuemin=\"0\" aria-valuemax=\"100\">0%</div></div><div id=\"uploadStatus\" class=\"small text-muted\">Preparing upload...</div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitUploadFile()\"><i class=\"fas fa-upload me-1\"></i>Upload Files</button></div></div></div></div><!-- JavaScript for file browser functionality --><script>\n\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t// Format permissions in the main table\n\t\tdocument.querySelectorAll('.permissions-display').forEach(element => {\n\t\t\tconst mode = element.getAttribute('data-mode');\n\t\t\tconst isDirectory = element.getAttribute('data-is-directory') === 'true';\n\t\t\tif (mode) {\n\t\t\t\telement.textContent = formatPermissions(mode, isDirectory);\n\t\t\t}\n\t\t});\n\t\t\n\t\t// Handle file browser action buttons (download, view, properties, delete)\n\t\tdocument.addEventListener('click', function(e) {\n\t\t\tconst button = e.target.closest('[data-action]');\n\t\t\tif (!button) return;\n\t\t\t\n\t\t\tconst action = button.getAttribute('data-action');\n\t\t\tconst path = button.getAttribute('data-path');\n\t\t\t\n\t\t\tif (!path) return;\n\t\t\t\n\t\t\tswitch(action) {\n\t\t\t\tcase 'download':\n\t\t\t\t\tdownloadFile(path);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'view':\n\t\t\t\t\tviewFile(path);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'properties':\n\t\t\t\t\tshowFileProperties(path);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'delete':\n\t\t\t\t\tif (confirm('Are you sure you want to delete \"' + path + '\"?')) {\n\t\t\t\t\t\tdeleteFile(path);\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t});\n\t\t\n\t\t// Initialize file manager event handlers from admin.js\n\t\tif (typeof setupFileManagerEventHandlers === 'function') {\n\t\t\tsetupFileManagerEventHandlers();\n\t\t}\n\t});\n\t\n\t// File browser specific functions\n\tfunction downloadFile(path) {\n\t\t// Open download URL in new tab\n\t\twindow.open('/api/files/download?path=' + encodeURIComponent(path), '_blank');\n\t}\n\t\n\tfunction viewFile(path) {\n\t\t// Open file viewer in new tab\n\t\twindow.open('/api/files/view?path=' + encodeURIComponent(path), '_blank');\n\t}\n\t\n\tfunction showFileProperties(path) {\n\t\t// Fetch file properties and show in modal\n\t\tfetch('/api/files/properties?path=' + encodeURIComponent(path))\n\t\t\t.then(response => response.json())\n\t\t\t.then(data => {\n\t\t\t\tif (data.error) {\n\t\t\t\t\talert('Error loading file properties: ' + data.error);\n\t\t\t\t} else {\n\t\t\t\t\tdisplayFileProperties(data);\n\t\t\t\t}\n\t\t\t})\n\t\t\t.catch(error => {\n\t\t\t\tconsole.error('Error fetching file properties:', error);\n\t\t\t\talert('Error loading file properties: ' + error.message);\n\t\t\t});\n\t}\n\t\n\tfunction displayFileProperties(data) {\n\t\t// Create a comprehensive modal for file properties\n\t\tconst modalHtml = '<div class=\"modal fade\" id=\"filePropertiesModal\" tabindex=\"-1\">' +\n\t\t\t'<div class=\"modal-dialog modal-lg\">' +\n\t\t\t'<div class=\"modal-content\">' +\n\t\t\t'<div class=\"modal-header\">' +\n\t\t\t'<h5 class=\"modal-title\"><i class=\"fas fa-info-circle me-2\"></i>Properties: ' + (data.name || 'Unknown') + '</h5>' +\n\t\t\t'<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-body\">' +\n\t\t\tcreateFilePropertiesContent(data) +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-footer\">' +\n\t\t\t'<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Remove existing modal if present\n\t\tconst existingModal = document.getElementById('filePropertiesModal');\n\t\tif (existingModal) {\n\t\t\texistingModal.remove();\n\t\t}\n\t\t\n\t\t// Add modal to body and show\n\t\tdocument.body.insertAdjacentHTML('beforeend', modalHtml);\n\t\tconst modal = new bootstrap.Modal(document.getElementById('filePropertiesModal'));\n\t\tmodal.show();\n\t\t\n\t\t// Remove modal when hidden\n\t\tdocument.getElementById('filePropertiesModal').addEventListener('hidden.bs.modal', function() {\n\t\t\tthis.remove();\n\t\t});\n\t}\n\t\n\tfunction createFilePropertiesContent(data) {\n\t\tlet html = '<div class=\"row\">' +\n\t\t\t'<div class=\"col-12\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-file me-1\"></i>Basic Information</h6>' +\n\t\t\t'<table class=\"table table-sm\">' +\n\t\t\t'<tr><td style=\"width: 120px;\"><strong>Name:</strong></td><td>' + (data.name || 'N/A') + '</td></tr>' +\n\t\t\t'<tr><td><strong>Full Path:</strong></td><td><code class=\"text-break\">' + (data.full_path || 'N/A') + '</code></td></tr>' +\n\t\t\t'<tr><td><strong>Type:</strong></td><td>' + (data.is_directory ? 'Directory' : 'File') + '</td></tr>';\n\t\t\n\t\tif (!data.is_directory) {\n\t\t\thtml += '<tr><td><strong>Size:</strong></td><td>' + (data.size_formatted || (data.size ? formatBytes(data.size) : 'N/A')) + '</td></tr>' +\n\t\t\t\t'<tr><td><strong>MIME Type:</strong></td><td>' + (data.mime_type || 'N/A') + '</td></tr>';\n\t\t}\n\t\t\n\t\thtml += '</table>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"row\">' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-clock me-1\"></i>Timestamps</h6>' +\n\t\t\t'<table class=\"table table-sm\">';\n\t\t\n\t\tif (data.modified_time) {\n\t\t\thtml += '<tr><td><strong>Modified:</strong></td><td>' + data.modified_time + '</td></tr>';\n\t\t}\n\t\tif (data.created_time) {\n\t\t\thtml += '<tr><td><strong>Created:</strong></td><td>' + data.created_time + '</td></tr>';\n\t\t}\n\t\t\n\t\thtml += '</table>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-shield-alt me-1\"></i>Permissions</h6>' +\n\t\t\t'<table class=\"table table-sm\">';\n\t\t\n\t\tif (data.file_mode) {\n\t\t\tconst rwxPermissions = formatPermissions(data.file_mode, data.is_directory);\n\t\t\thtml += '<tr><td><strong>Permissions:</strong></td><td><code>' + rwxPermissions + '</code></td></tr>';\n\t\t}\n\t\tif (data.uid !== undefined) {\n\t\t\thtml += '<tr><td><strong>User ID:</strong></td><td>' + data.uid + '</td></tr>';\n\t\t}\n\t\tif (data.gid !== undefined) {\n\t\t\thtml += '<tr><td><strong>Group ID:</strong></td><td>' + data.gid + '</td></tr>';\n\t\t}\n\t\t\n\t\thtml += '</table>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Add advanced info\n\t\thtml += '<div class=\"row\">' +\n\t\t\t'<div class=\"col-12\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-cog me-1\"></i>Advanced</h6>' +\n\t\t\t'<table class=\"table table-sm\">';\n\t\t\n\t\tif (data.chunk_count) {\n\t\t\thtml += '<tr><td style=\"width: 120px;\"><strong>Chunks:</strong></td><td>' + data.chunk_count + '</td></tr>';\n\t\t}\n\t\tif (data.ttl_formatted) {\n\t\t\thtml += '<tr><td><strong>TTL:</strong></td><td>' + data.ttl_formatted + '</td></tr>';\n\t\t}\n\t\t\n\t\thtml += '</table>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Add chunk details if available (show top 5)\n\t\tif (data.chunks && data.chunks.length > 0) {\n\t\t\tconst chunksToShow = data.chunks.slice(0, 5);\n\t\t\thtml += '<div class=\"row mt-3\">' +\n\t\t\t\t'<div class=\"col-12\">' +\n\t\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-puzzle-piece me-1\"></i>Chunk Details' +\n\t\t\t\t(data.chunk_count > 5 ? ' (Top 5 of ' + data.chunk_count + ')' : ' (' + data.chunk_count + ')') +\n\t\t\t\t'</h6>' +\n\t\t\t\t'<div class=\"table-responsive\" style=\"max-height: 200px; overflow-y: auto;\">' +\n\t\t\t\t'<table class=\"table table-sm table-striped\">' +\n\t\t\t\t'<thead>' +\n\t\t\t\t'<tr>' +\n\t\t\t\t'<th>File ID</th>' +\n\t\t\t\t'<th>Offset</th>' +\n\t\t\t\t'<th>Size</th>' +\n\t\t\t\t'<th>ETag</th>' +\n\t\t\t\t'</tr>' +\n\t\t\t\t'</thead>' +\n\t\t\t\t'<tbody>';\n\t\t\t\n\t\t\tchunksToShow.forEach(chunk => {\n\t\t\t\thtml += '<tr>' +\n\t\t\t\t\t'<td><code class=\"small\">' + (chunk.file_id || 'N/A') + '</code></td>' +\n\t\t\t\t\t'<td>' + formatBytes(chunk.offset || 0) + '</td>' +\n\t\t\t\t\t'<td>' + formatBytes(chunk.size || 0) + '</td>' +\n\t\t\t\t\t'<td><code class=\"small\">' + (chunk.e_tag || 'N/A') + '</code></td>' +\n\t\t\t\t\t'</tr>';\n\t\t\t});\n\t\t\t\n\t\t\thtml += '</tbody>' +\n\t\t\t\t'</table>' +\n\t\t\t\t'</div>' +\n\t\t\t\t'</div>' +\n\t\t\t\t'</div>';\n\t\t}\n\t\t\n\t\t// Add extended attributes if present\n\t\tif (data.extended && Object.keys(data.extended).length > 0) {\n\t\t\thtml += '<div class=\"row\">' +\n\t\t\t\t'<div class=\"col-12\">' +\n\t\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-tags me-1\"></i>Extended Attributes</h6>' +\n\t\t\t\t'<table class=\"table table-sm\">';\n\t\t\t\n\t\t\tfor (const [key, value] of Object.entries(data.extended)) {\n\t\t\t\thtml += '<tr><td><strong>' + key + ':</strong></td><td>' + value + '</td></tr>';\n\t\t\t}\n\t\t\t\n\t\t\thtml += '</table>' +\n\t\t\t\t'</div>' +\n\t\t\t\t'</div>';\n\t\t}\n\t\t\n\t\treturn html;\n\t}\n\t\n\tfunction uploadFile() {\n\t\tconst modal = new bootstrap.Modal(document.getElementById('uploadFileModal'));\n\t\tmodal.show();\n\t}\n\t\n\tfunction toggleSelectAll() {\n\t\tconst selectAllCheckbox = document.getElementById('selectAll');\n\t\tconst checkboxes = document.querySelectorAll('.file-checkbox');\n\t\t\n\t\tcheckboxes.forEach(checkbox => {\n\t\t\tcheckbox.checked = selectAllCheckbox.checked;\n\t\t});\n\t\t\n\t\tupdateDeleteSelectedButton();\n\t}\n\t\n\tfunction updateDeleteSelectedButton() {\n\t\tconst checkboxes = document.querySelectorAll('.file-checkbox:checked');\n\t\tconst deleteBtn = document.getElementById('deleteSelectedBtn');\n\t\t\n\t\tif (checkboxes.length > 0) {\n\t\t\tdeleteBtn.style.display = 'inline-block';\n\t\t} else {\n\t\t\tdeleteBtn.style.display = 'none';\n\t\t}\n\t}\n\t\n\t// Helper function to format bytes\n\tfunction formatBytes(bytes) {\n\t\tif (bytes === 0) return '0 Bytes';\n\t\tconst k = 1024;\n\t\tconst sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n\t\tconst i = Math.floor(Math.log(bytes) / Math.log(k));\n\t\treturn parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n\t}\n\t\n\t// Helper function to format permissions in rwxrwxrwx format\n\tfunction formatPermissions(mode, isDirectory) {\n\t\t// Check if mode is already in rwxrwxrwx format (e.g., \"drwxr-xr-x\" or \"-rw-r--r--\")\n\t\tif (mode && (mode.startsWith('d') || mode.startsWith('-') || mode.startsWith('l')) && mode.length === 10) {\n\t\t\treturn mode; // Already formatted\n\t\t}\n\t\t\n\t\t// Convert to number - could be octal string or decimal\n\t\tlet permissions;\n\t\tif (typeof mode === 'string') {\n\t\t\t// Try parsing as octal first, then decimal\n\t\t\tif (mode.startsWith('0') && mode.length <= 4) {\n\t\t\t\tpermissions = parseInt(mode, 8);\n\t\t\t} else {\n\t\t\t\tpermissions = parseInt(mode, 10);\n\t\t\t}\n\t\t} else {\n\t\t\tpermissions = parseInt(mode, 10);\n\t\t}\n\t\t\n\t\tif (isNaN(permissions)) {\n\t\t\treturn isDirectory ? 'drwxr-xr-x' : '-rw-r--r--'; // Default fallback\n\t\t}\n\t\t\n\t\t// Handle Go's os.ModeDir conversion\n\t\t// Go's os.ModeDir is 0x80000000 (2147483648), but Unix S_IFDIR is 0o40000 (16384)\n\t\tlet fileType = '-';\n\t\t\n\t\t// Check for Go's os.ModeDir flag\n\t\tif (permissions & 0x80000000) {\n\t\t\tfileType = 'd';\n\t\t}\n\t\t// Check for standard Unix file type bits\n\t\telse if ((permissions & 0xF000) === 0x4000) { // S_IFDIR (0o40000)\n\t\t\tfileType = 'd';\n\t\t} else if ((permissions & 0xF000) === 0x8000) { // S_IFREG (0o100000)\n\t\t\tfileType = '-';\n\t\t} else if ((permissions & 0xF000) === 0xA000) { // S_IFLNK (0o120000)\n\t\t\tfileType = 'l';\n\t\t} else if ((permissions & 0xF000) === 0x2000) { // S_IFCHR (0o020000)\n\t\t\tfileType = 'c';\n\t\t} else if ((permissions & 0xF000) === 0x6000) { // S_IFBLK (0o060000)\n\t\t\tfileType = 'b';\n\t\t} else if ((permissions & 0xF000) === 0x1000) { // S_IFIFO (0o010000)\n\t\t\tfileType = 'p';\n\t\t} else if ((permissions & 0xF000) === 0xC000) { // S_IFSOCK (0o140000)\n\t\t\tfileType = 's';\n\t\t}\n\t\t// Fallback to isDirectory parameter if file type detection fails\n\t\telse if (isDirectory) {\n\t\t\tfileType = 'd';\n\t\t}\n\t\t\n\t\t// Permission bits (always use the lower 12 bits for permissions)\n\t\tconst owner = (permissions >> 6) & 7;\n\t\tconst group = (permissions >> 3) & 7;\n\t\tconst others = permissions & 7;\n\t\t\n\t\t// Convert number to rwx format\n\t\tfunction numToRwx(num) {\n\t\t\tconst r = (num & 4) ? 'r' : '-';\n\t\t\tconst w = (num & 2) ? 'w' : '-';\n\t\t\tconst x = (num & 1) ? 'x' : '-';\n\t\t\treturn r + w + x;\n\t\t}\n\t\t\n\t\treturn fileType + numToRwx(owner) + numToRwx(group) + numToRwx(others);\n\t}\n\t\n\tfunction exportFileList() {\n\t\t// Simple CSV export of file list\n\t\tconst rows = Array.from(document.querySelectorAll('#fileTable tbody tr')).map(row => {\n\t\t\tconst cells = row.querySelectorAll('td');\n\t\t\tif (cells.length > 1) {\n\t\t\t\treturn {\n\t\t\t\t\tname: cells[1].textContent.trim(),\n\t\t\t\t\tsize: cells[2].textContent.trim(),\n\t\t\t\t\ttype: cells[3].textContent.trim(),\n\t\t\t\t\tmodified: cells[4].textContent.trim(),\n\t\t\t\t\tpermissions: cells[5].textContent.trim()\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t}).filter(row => row !== null);\n\t\t\n\t\tconst csvContent = \"data:text/csv;charset=utf-8,\" + \n\t\t\t\"Name,Size,Type,Modified,Permissions\\n\" +\n\t\t\trows.map(r => '\"' + r.name + '\",\"' + r.size + '\",\"' + r.type + '\",\"' + r.modified + '\",\"' + r.permissions + '\"').join(\"\\n\");\n\t\t\n\t\tconst encodedUri = encodeURI(csvContent);\n\t\tconst link = document.createElement(\"a\");\n\t\tlink.setAttribute(\"href\", encodedUri);\n\t\tlink.setAttribute(\"download\", \"files.csv\");\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n\t\n\t// Handle file checkbox changes\n\tdocument.addEventListener('change', function(e) {\n\t\tif (e.target.classList.contains('file-checkbox')) {\n\t\t\tupdateDeleteSelectedButton();\n\t\t}\n\t});\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/object_store_users.templ b/weed/admin/view/app/object_store_users.templ
index 7c457a3d8..dedd258e2 100644
--- a/weed/admin/view/app/object_store_users.templ
+++ b/weed/admin/view/app/object_store_users.templ
@@ -317,7 +317,355 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
<!-- JavaScript for user management -->
<script>
- // User management functions will be included in admin.js
+ document.addEventListener('DOMContentLoaded', function() {
+ // Event delegation for user action buttons
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const action = button.getAttribute('data-action');
+ const username = button.getAttribute('data-username');
+
+ switch (action) {
+ case 'show-user-details':
+ showUserDetails(username);
+ break;
+ case 'edit-user':
+ editUser(username);
+ break;
+ case 'manage-access-keys':
+ manageAccessKeys(username);
+ break;
+ case 'delete-user':
+ deleteUser(username);
+ break;
+ }
+ });
+ });
+
+ // Show user details modal
+ async function showUserDetails(username) {
+ try {
+ const response = await fetch(`/api/users/${username}`);
+ if (response.ok) {
+ const user = await response.json();
+ document.getElementById('userDetailsContent').innerHTML = createUserDetailsContent(user);
+ const modal = new bootstrap.Modal(document.getElementById('userDetailsModal'));
+ modal.show();
+ } else {
+ showErrorMessage('Failed to load user details');
+ }
+ } catch (error) {
+ console.error('Error loading user details:', error);
+ showErrorMessage('Failed to load user details');
+ }
+ }
+
+ // Edit user function
+ async function editUser(username) {
+ try {
+ const response = await fetch(`/api/users/${username}`);
+ if (response.ok) {
+ const user = await response.json();
+
+ // Populate edit form
+ document.getElementById('editUsername').value = username;
+ document.getElementById('editEmail').value = user.email || '';
+
+ // Set selected actions
+ const actionsSelect = document.getElementById('editActions');
+ Array.from(actionsSelect.options).forEach(option => {
+ option.selected = user.actions && user.actions.includes(option.value);
+ });
+
+ // Show modal
+ const modal = new bootstrap.Modal(document.getElementById('editUserModal'));
+ modal.show();
+ } else {
+ showErrorMessage('Failed to load user details');
+ }
+ } catch (error) {
+ console.error('Error loading user:', error);
+ showErrorMessage('Failed to load user details');
+ }
+ }
+
+ // Manage access keys function
+ async function manageAccessKeys(username) {
+ try {
+ const response = await fetch(`/api/users/${username}`);
+ if (response.ok) {
+ const user = await response.json();
+ document.getElementById('accessKeysUsername').textContent = username;
+ document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);
+ const modal = new bootstrap.Modal(document.getElementById('accessKeysModal'));
+ modal.show();
+ } else {
+ showErrorMessage('Failed to load access keys');
+ }
+ } catch (error) {
+ console.error('Error loading access keys:', error);
+ showErrorMessage('Failed to load access keys');
+ }
+ }
+
+ // Delete user function
+ async function deleteUser(username) {
+ if (confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
+ try {
+ const response = await fetch(`/api/users/${username}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ showSuccessMessage('User deleted successfully');
+ setTimeout(() => window.location.reload(), 1000);
+ } else {
+ const error = await response.json();
+ showErrorMessage('Failed to delete user: ' + (error.error || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error deleting user:', error);
+ showErrorMessage('Failed to delete user: ' + error.message);
+ }
+ }
+ }
+
+ // Handle create user form submission
+ async function handleCreateUser() {
+ const form = document.getElementById('createUserForm');
+ const formData = new FormData(form);
+
+ const userData = {
+ username: formData.get('username'),
+ email: formData.get('email'),
+ actions: Array.from(document.getElementById('actions').selectedOptions).map(option => option.value),
+ generate_key: document.getElementById('generateKey').checked
+ };
+
+ try {
+ const response = await fetch('/api/users', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(userData)
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ showSuccessMessage('User created successfully');
+
+ // Show the created access key if generated
+ if (result.user && result.user.access_key) {
+ showNewAccessKeyModal(result.user);
+ }
+
+ // Close modal and refresh page
+ const modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal'));
+ modal.hide();
+ form.reset();
+ setTimeout(() => window.location.reload(), 1000);
+ } else {
+ const error = await response.json();
+ showErrorMessage('Failed to create user: ' + (error.error || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error creating user:', error);
+ showErrorMessage('Failed to create user: ' + error.message);
+ }
+ }
+
+ // Handle update user form submission
+ async function handleUpdateUser() {
+ const username = document.getElementById('editUsername').value;
+ const formData = new FormData(document.getElementById('editUserForm'));
+
+ const userData = {
+ email: formData.get('email'),
+ actions: Array.from(document.getElementById('editActions').selectedOptions).map(option => option.value)
+ };
+
+ try {
+ const response = await fetch(`/api/users/${username}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(userData)
+ });
+
+ if (response.ok) {
+ showSuccessMessage('User updated successfully');
+
+ // Close modal and refresh page
+ const modal = bootstrap.Modal.getInstance(document.getElementById('editUserModal'));
+ modal.hide();
+ setTimeout(() => window.location.reload(), 1000);
+ } else {
+ const error = await response.json();
+ showErrorMessage('Failed to update user: ' + (error.error || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error updating user:', error);
+ showErrorMessage('Failed to update user: ' + error.message);
+ }
+ }
+
+ // Create user details content
+ function createUserDetailsContent(user) {
+ var detailsHtml = '<div class="row">';
+ detailsHtml += '<div class="col-md-6">';
+ detailsHtml += '<h6 class="text-muted">Basic Information</h6>';
+ detailsHtml += '<table class="table table-sm">';
+ detailsHtml += '<tr><td><strong>Username:</strong></td><td>' + escapeHtml(user.username) + '</td></tr>';
+ detailsHtml += '<tr><td><strong>Email:</strong></td><td>' + escapeHtml(user.email || 'Not set') + '</td></tr>';
+ detailsHtml += '</table>';
+ detailsHtml += '</div>';
+ detailsHtml += '<div class="col-md-6">';
+ detailsHtml += '<h6 class="text-muted">Permissions</h6>';
+ detailsHtml += '<div class="mb-3">';
+ if (user.actions && user.actions.length > 0) {
+ detailsHtml += user.actions.map(function(action) {
+ return '<span class="badge bg-info me-1">' + action + '</span>';
+ }).join('');
+ } else {
+ detailsHtml += '<span class="text-muted">No permissions assigned</span>';
+ }
+ detailsHtml += '</div>';
+ detailsHtml += '<h6 class="text-muted">Access Keys</h6>';
+ if (user.access_keys && user.access_keys.length > 0) {
+ detailsHtml += '<div class="mb-2">';
+ user.access_keys.forEach(function(key) {
+ detailsHtml += '<div><code class="text-muted">' + key.access_key + '</code></div>';
+ });
+ detailsHtml += '</div>';
+ } else {
+ detailsHtml += '<p class="text-muted">No access keys</p>';
+ }
+ detailsHtml += '</div>';
+ detailsHtml += '</div>';
+ return detailsHtml;
+ }
+
+ // Create access keys content
+ function createAccessKeysContent(user) {
+ if (!user.access_keys || user.access_keys.length === 0) {
+ return '<p class="text-muted">No access keys available</p>';
+ }
+
+ var keysHtml = '<div class="table-responsive">';
+ keysHtml += '<table class="table table-sm">';
+ keysHtml += '<thead><tr><th>Access Key</th><th>Status</th><th>Actions</th></tr></thead>';
+ keysHtml += '<tbody>';
+
+ user.access_keys.forEach(function(key) {
+ keysHtml += '<tr>';
+ keysHtml += '<td><code>' + key.access_key + '</code></td>';
+ keysHtml += '<td><span class="badge bg-success">Active</span></td>';
+ keysHtml += '<td>';
+ keysHtml += '<button class="btn btn-outline-danger btn-sm" onclick="deleteAccessKey(\'' + user.username + '\', \'' + key.access_key + '\')">';
+ keysHtml += '<i class="fas fa-trash"></i> Delete';
+ keysHtml += '</button>';
+ keysHtml += '</td>';
+ keysHtml += '</tr>';
+ });
+
+ keysHtml += '</tbody>';
+ keysHtml += '</table>';
+ keysHtml += '</div>';
+ return keysHtml;
+ }
+
+ // Create new access key
+ async function createAccessKey() {
+ const username = document.getElementById('accessKeysUsername').textContent;
+
+ try {
+ const response = await fetch(`/api/users/${username}/access-keys`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({})
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ showSuccessMessage('Access key created successfully');
+
+ // Refresh access keys display
+ const userResponse = await fetch(`/api/users/${username}`);
+ if (userResponse.ok) {
+ const user = await userResponse.json();
+ document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);
+ }
+ } else {
+ const error = await response.json();
+ showErrorMessage('Failed to create access key: ' + (error.error || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error creating access key:', error);
+ showErrorMessage('Failed to create access key: ' + error.message);
+ }
+ }
+
+ // Delete access key
+ async function deleteAccessKey(username, accessKey) {
+ if (confirm('Are you sure you want to delete this access key?')) {
+ try {
+ const response = await fetch(`/api/users/${username}/access-keys/${accessKey}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ showSuccessMessage('Access key deleted successfully');
+
+ // Refresh access keys display
+ const userResponse = await fetch(`/api/users/${username}`);
+ if (userResponse.ok) {
+ const user = await userResponse.json();
+ document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);
+ }
+ } else {
+ const error = await response.json();
+ showErrorMessage('Failed to delete access key: ' + (error.error || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error deleting access key:', error);
+ showErrorMessage('Failed to delete access key: ' + error.message);
+ }
+ }
+ }
+
+ // Show new access key modal (when user is created with generated key)
+ function showNewAccessKeyModal(user) {
+ // Create a simple alert for now - could be enhanced with a dedicated modal
+ var message = 'New user created!\n\n';
+ message += 'Username: ' + user.username + '\n';
+ message += 'Access Key: ' + user.access_key + '\n';
+ message += 'Secret Key: ' + user.secret_key + '\n\n';
+ message += 'Please save these credentials securely.';
+ alert(message);
+ }
+
+ // Utility functions
+ function showSuccessMessage(message) {
+ // Simple implementation - could be enhanced with toast notifications
+ alert('Success: ' + message);
+ }
+
+ function showErrorMessage(message) {
+ // Simple implementation - could be enhanced with toast notifications
+ alert('Error: ' + message);
+ }
+
+ function escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
</script>
}
diff --git a/weed/admin/view/app/object_store_users_templ.go b/weed/admin/view/app/object_store_users_templ.go
index a4a194d59..8d08d5161 100644
--- a/weed/admin/view/app/object_store_users_templ.go
+++ b/weed/admin/view/app/object_store_users_templ.go
@@ -193,7 +193,7 @@ func ObjectStoreUsers(data dash.ObjectStoreUsersData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</small></div></div></div><!-- Create User Modal --><div class=\"modal fade\" id=\"createUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-plus me-2\"></i>Create New User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"createUserForm\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username *</label> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div><div class=\"mb-3\"><label for=\"email\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"email\" name=\"email\"></div><div class=\"mb-3\"><label for=\"actions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"actions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple permissions</small></div><div class=\"mb-3 form-check\"><input type=\"checkbox\" class=\"form-check-input\" id=\"generateKey\" name=\"generateKey\" checked> <label class=\"form-check-label\" for=\"generateKey\">Generate access key automatically</label></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleCreateUser()\">Create User</button></div></div></div></div><!-- Edit User Modal --><div class=\"modal fade\" id=\"editUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-edit me-2\"></i>Edit User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"editUserForm\"><input type=\"hidden\" id=\"editUsername\" name=\"username\"><div class=\"mb-3\"><label for=\"editEmail\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"editEmail\" name=\"email\"></div><div class=\"mb-3\"><label for=\"editActions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"editActions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleUpdateUser()\">Update User</button></div></div></div></div><!-- User Details Modal --><div class=\"modal fade\" id=\"userDetailsModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user me-2\"></i>User Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\" id=\"userDetailsContent\"><!-- Content will be loaded dynamically --></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- Access Keys Management Modal --><div class=\"modal fade\" id=\"accessKeysModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-key me-2\"></i>Manage Access Keys</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><div class=\"d-flex justify-content-between align-items-center mb-3\"><h6>Access Keys for <span id=\"accessKeysUsername\"></span></h6><button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createAccessKey()\"><i class=\"fas fa-plus me-1\"></i>Create New Key</button></div><div id=\"accessKeysContent\"><!-- Content will be loaded dynamically --></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- JavaScript for user management --><script>\n // User management functions will be included in admin.js\n </script>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</small></div></div></div><!-- Create User Modal --><div class=\"modal fade\" id=\"createUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-plus me-2\"></i>Create New User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"createUserForm\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username *</label> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div><div class=\"mb-3\"><label for=\"email\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"email\" name=\"email\"></div><div class=\"mb-3\"><label for=\"actions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"actions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple permissions</small></div><div class=\"mb-3 form-check\"><input type=\"checkbox\" class=\"form-check-input\" id=\"generateKey\" name=\"generateKey\" checked> <label class=\"form-check-label\" for=\"generateKey\">Generate access key automatically</label></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleCreateUser()\">Create User</button></div></div></div></div><!-- Edit User Modal --><div class=\"modal fade\" id=\"editUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-edit me-2\"></i>Edit User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"editUserForm\"><input type=\"hidden\" id=\"editUsername\" name=\"username\"><div class=\"mb-3\"><label for=\"editEmail\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"editEmail\" name=\"email\"></div><div class=\"mb-3\"><label for=\"editActions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"editActions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleUpdateUser()\">Update User</button></div></div></div></div><!-- User Details Modal --><div class=\"modal fade\" id=\"userDetailsModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user me-2\"></i>User Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\" id=\"userDetailsContent\"><!-- Content will be loaded dynamically --></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- Access Keys Management Modal --><div class=\"modal fade\" id=\"accessKeysModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-key me-2\"></i>Manage Access Keys</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><div class=\"d-flex justify-content-between align-items-center mb-3\"><h6>Access Keys for <span id=\"accessKeysUsername\"></span></h6><button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createAccessKey()\"><i class=\"fas fa-plus me-1\"></i>Create New Key</button></div><div id=\"accessKeysContent\"><!-- Content will be loaded dynamically --></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- JavaScript for user management --><script>\n document.addEventListener('DOMContentLoaded', function() {\n // Event delegation for user action buttons\n document.addEventListener('click', function(e) {\n const button = e.target.closest('[data-action]');\n if (!button) return;\n \n const action = button.getAttribute('data-action');\n const username = button.getAttribute('data-username');\n \n switch (action) {\n case 'show-user-details':\n showUserDetails(username);\n break;\n case 'edit-user':\n editUser(username);\n break;\n case 'manage-access-keys':\n manageAccessKeys(username);\n break;\n case 'delete-user':\n deleteUser(username);\n break;\n }\n });\n });\n\n // Show user details modal\n async function showUserDetails(username) {\n try {\n const response = await fetch(`/api/users/${username}`);\n if (response.ok) {\n const user = await response.json();\n document.getElementById('userDetailsContent').innerHTML = createUserDetailsContent(user);\n const modal = new bootstrap.Modal(document.getElementById('userDetailsModal'));\n modal.show();\n } else {\n showErrorMessage('Failed to load user details');\n }\n } catch (error) {\n console.error('Error loading user details:', error);\n showErrorMessage('Failed to load user details');\n }\n }\n\n // Edit user function\n async function editUser(username) {\n try {\n const response = await fetch(`/api/users/${username}`);\n if (response.ok) {\n const user = await response.json();\n \n // Populate edit form\n document.getElementById('editUsername').value = username;\n document.getElementById('editEmail').value = user.email || '';\n \n // Set selected actions\n const actionsSelect = document.getElementById('editActions');\n Array.from(actionsSelect.options).forEach(option => {\n option.selected = user.actions && user.actions.includes(option.value);\n });\n \n // Show modal\n const modal = new bootstrap.Modal(document.getElementById('editUserModal'));\n modal.show();\n } else {\n showErrorMessage('Failed to load user details');\n }\n } catch (error) {\n console.error('Error loading user:', error);\n showErrorMessage('Failed to load user details');\n }\n }\n\n // Manage access keys function\n async function manageAccessKeys(username) {\n try {\n const response = await fetch(`/api/users/${username}`);\n if (response.ok) {\n const user = await response.json();\n document.getElementById('accessKeysUsername').textContent = username;\n document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);\n const modal = new bootstrap.Modal(document.getElementById('accessKeysModal'));\n modal.show();\n } else {\n showErrorMessage('Failed to load access keys');\n }\n } catch (error) {\n console.error('Error loading access keys:', error);\n showErrorMessage('Failed to load access keys');\n }\n }\n\n // Delete user function\n async function deleteUser(username) {\n if (confirm(`Are you sure you want to delete user \"${username}\"? This action cannot be undone.`)) {\n try {\n const response = await fetch(`/api/users/${username}`, {\n method: 'DELETE'\n });\n \n if (response.ok) {\n showSuccessMessage('User deleted successfully');\n setTimeout(() => window.location.reload(), 1000);\n } else {\n const error = await response.json();\n showErrorMessage('Failed to delete user: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error deleting user:', error);\n showErrorMessage('Failed to delete user: ' + error.message);\n }\n }\n }\n\n // Handle create user form submission\n async function handleCreateUser() {\n const form = document.getElementById('createUserForm');\n const formData = new FormData(form);\n \n const userData = {\n username: formData.get('username'),\n email: formData.get('email'),\n actions: Array.from(document.getElementById('actions').selectedOptions).map(option => option.value),\n generate_key: document.getElementById('generateKey').checked\n };\n \n try {\n const response = await fetch('/api/users', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(userData)\n });\n \n if (response.ok) {\n const result = await response.json();\n showSuccessMessage('User created successfully');\n \n // Show the created access key if generated\n if (result.user && result.user.access_key) {\n showNewAccessKeyModal(result.user);\n }\n \n // Close modal and refresh page\n const modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal'));\n modal.hide();\n form.reset();\n setTimeout(() => window.location.reload(), 1000);\n } else {\n const error = await response.json();\n showErrorMessage('Failed to create user: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error creating user:', error);\n showErrorMessage('Failed to create user: ' + error.message);\n }\n }\n\n // Handle update user form submission\n async function handleUpdateUser() {\n const username = document.getElementById('editUsername').value;\n const formData = new FormData(document.getElementById('editUserForm'));\n \n const userData = {\n email: formData.get('email'),\n actions: Array.from(document.getElementById('editActions').selectedOptions).map(option => option.value)\n };\n \n try {\n const response = await fetch(`/api/users/${username}`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(userData)\n });\n \n if (response.ok) {\n showSuccessMessage('User updated successfully');\n \n // Close modal and refresh page\n const modal = bootstrap.Modal.getInstance(document.getElementById('editUserModal'));\n modal.hide();\n setTimeout(() => window.location.reload(), 1000);\n } else {\n const error = await response.json();\n showErrorMessage('Failed to update user: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error updating user:', error);\n showErrorMessage('Failed to update user: ' + error.message);\n }\n }\n\n // Create user details content\n function createUserDetailsContent(user) {\n var detailsHtml = '<div class=\"row\">';\n detailsHtml += '<div class=\"col-md-6\">';\n detailsHtml += '<h6 class=\"text-muted\">Basic Information</h6>';\n detailsHtml += '<table class=\"table table-sm\">';\n detailsHtml += '<tr><td><strong>Username:</strong></td><td>' + escapeHtml(user.username) + '</td></tr>';\n detailsHtml += '<tr><td><strong>Email:</strong></td><td>' + escapeHtml(user.email || 'Not set') + '</td></tr>';\n detailsHtml += '</table>';\n detailsHtml += '</div>';\n detailsHtml += '<div class=\"col-md-6\">';\n detailsHtml += '<h6 class=\"text-muted\">Permissions</h6>';\n detailsHtml += '<div class=\"mb-3\">';\n if (user.actions && user.actions.length > 0) {\n detailsHtml += user.actions.map(function(action) {\n return '<span class=\"badge bg-info me-1\">' + action + '</span>';\n }).join('');\n } else {\n detailsHtml += '<span class=\"text-muted\">No permissions assigned</span>';\n }\n detailsHtml += '</div>';\n detailsHtml += '<h6 class=\"text-muted\">Access Keys</h6>';\n if (user.access_keys && user.access_keys.length > 0) {\n detailsHtml += '<div class=\"mb-2\">';\n user.access_keys.forEach(function(key) {\n detailsHtml += '<div><code class=\"text-muted\">' + key.access_key + '</code></div>';\n });\n detailsHtml += '</div>';\n } else {\n detailsHtml += '<p class=\"text-muted\">No access keys</p>';\n }\n detailsHtml += '</div>';\n detailsHtml += '</div>';\n return detailsHtml;\n }\n\n // Create access keys content\n function createAccessKeysContent(user) {\n if (!user.access_keys || user.access_keys.length === 0) {\n return '<p class=\"text-muted\">No access keys available</p>';\n }\n \n var keysHtml = '<div class=\"table-responsive\">';\n keysHtml += '<table class=\"table table-sm\">';\n keysHtml += '<thead><tr><th>Access Key</th><th>Status</th><th>Actions</th></tr></thead>';\n keysHtml += '<tbody>';\n \n user.access_keys.forEach(function(key) {\n keysHtml += '<tr>';\n keysHtml += '<td><code>' + key.access_key + '</code></td>';\n keysHtml += '<td><span class=\"badge bg-success\">Active</span></td>';\n keysHtml += '<td>';\n keysHtml += '<button class=\"btn btn-outline-danger btn-sm\" onclick=\"deleteAccessKey(\\'' + user.username + '\\', \\'' + key.access_key + '\\')\">';\n keysHtml += '<i class=\"fas fa-trash\"></i> Delete';\n keysHtml += '</button>';\n keysHtml += '</td>';\n keysHtml += '</tr>';\n });\n \n keysHtml += '</tbody>';\n keysHtml += '</table>';\n keysHtml += '</div>';\n return keysHtml;\n }\n\n // Create new access key\n async function createAccessKey() {\n const username = document.getElementById('accessKeysUsername').textContent;\n \n try {\n const response = await fetch(`/api/users/${username}/access-keys`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({})\n });\n \n if (response.ok) {\n const result = await response.json();\n showSuccessMessage('Access key created successfully');\n \n // Refresh access keys display\n const userResponse = await fetch(`/api/users/${username}`);\n if (userResponse.ok) {\n const user = await userResponse.json();\n document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);\n }\n } else {\n const error = await response.json();\n showErrorMessage('Failed to create access key: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error creating access key:', error);\n showErrorMessage('Failed to create access key: ' + error.message);\n }\n }\n\n // Delete access key\n async function deleteAccessKey(username, accessKey) {\n if (confirm('Are you sure you want to delete this access key?')) {\n try {\n const response = await fetch(`/api/users/${username}/access-keys/${accessKey}`, {\n method: 'DELETE'\n });\n \n if (response.ok) {\n showSuccessMessage('Access key deleted successfully');\n \n // Refresh access keys display\n const userResponse = await fetch(`/api/users/${username}`);\n if (userResponse.ok) {\n const user = await userResponse.json();\n document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);\n }\n } else {\n const error = await response.json();\n showErrorMessage('Failed to delete access key: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error deleting access key:', error);\n showErrorMessage('Failed to delete access key: ' + error.message);\n }\n }\n }\n\n // Show new access key modal (when user is created with generated key)\n function showNewAccessKeyModal(user) {\n // Create a simple alert for now - could be enhanced with a dedicated modal\n var message = 'New user created!\\n\\n';\n message += 'Username: ' + user.username + '\\n';\n message += 'Access Key: ' + user.access_key + '\\n';\n message += 'Secret Key: ' + user.secret_key + '\\n\\n';\n message += 'Please save these credentials securely.';\n alert(message);\n }\n\n // Utility functions\n function showSuccessMessage(message) {\n // Simple implementation - could be enhanced with toast notifications\n alert('Success: ' + message);\n }\n\n function showErrorMessage(message) {\n // Simple implementation - could be enhanced with toast notifications\n alert('Error: ' + message);\n }\n\n function escapeHtml(text) {\n if (!text) return '';\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/policies.templ b/weed/admin/view/app/policies.templ
new file mode 100644
index 000000000..e613d535e
--- /dev/null
+++ b/weed/admin/view/app/policies.templ
@@ -0,0 +1,658 @@
+package app
+
+import (
+ "fmt"
+ "github.com/seaweedfs/seaweedfs/weed/admin/dash"
+)
+
+templ Policies(data dash.PoliciesData) {
+ <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
+ <h1 class="h2">
+ <i class="fas fa-shield-alt me-2"></i>IAM Policies
+ </h1>
+ <div class="btn-toolbar mb-2 mb-md-0">
+ <div class="btn-group me-2">
+ <button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createPolicyModal">
+ <i class="fas fa-plus me-1"></i>Create Policy
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div id="policies-content">
+ <!-- Summary Cards -->
+ <div class="row mb-4">
+ <div class="col-xl-4 col-md-6 mb-4">
+ <div class="card border-left-primary shadow h-100 py-2">
+ <div class="card-body">
+ <div class="row no-gutters align-items-center">
+ <div class="col mr-2">
+ <div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
+ Total Policies
+ </div>
+ <div class="h5 mb-0 font-weight-bold text-gray-800">
+ {fmt.Sprintf("%d", data.TotalPolicies)}
+ </div>
+ </div>
+ <div class="col-auto">
+ <i class="fas fa-shield-alt fa-2x text-gray-300"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-xl-4 col-md-6 mb-4">
+ <div class="card border-left-success shadow h-100 py-2">
+ <div class="card-body">
+ <div class="row no-gutters align-items-center">
+ <div class="col mr-2">
+ <div class="text-xs font-weight-bold text-success text-uppercase mb-1">
+ Active Policies
+ </div>
+ <div class="h5 mb-0 font-weight-bold text-gray-800">
+ {fmt.Sprintf("%d", data.TotalPolicies)}
+ </div>
+ </div>
+ <div class="col-auto">
+ <i class="fas fa-check-circle fa-2x text-gray-300"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-xl-4 col-md-6 mb-4">
+ <div class="card border-left-info shadow h-100 py-2">
+ <div class="card-body">
+ <div class="row no-gutters align-items-center">
+ <div class="col mr-2">
+ <div class="text-xs font-weight-bold text-info text-uppercase mb-1">
+ Last Updated
+ </div>
+ <div class="h5 mb-0 font-weight-bold text-gray-800">
+ {data.LastUpdated.Format("15:04")}
+ </div>
+ </div>
+ <div class="col-auto">
+ <i class="fas fa-clock fa-2x text-gray-300"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Policies Table -->
+ <div class="row">
+ <div class="col-12">
+ <div class="card shadow mb-4">
+ <div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
+ <h6 class="m-0 font-weight-bold text-primary">
+ <i class="fas fa-shield-alt me-2"></i>IAM Policies
+ </h6>
+ <div class="dropdown no-arrow">
+ <a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
+ <i class="fas fa-ellipsis-v fa-sm fa-fw text-gray-400"></i>
+ </a>
+ <div class="dropdown-menu dropdown-menu-right shadow animated--fade-in">
+ <div class="dropdown-header">Actions:</div>
+ <a class="dropdown-item" href="#">
+ <i class="fas fa-download me-2"></i>Export List
+ </a>
+ </div>
+ </div>
+ </div>
+ <div class="card-body">
+ <div class="table-responsive">
+ <table class="table table-hover" width="100%" cellspacing="0">
+ <thead>
+ <tr>
+ <th>Policy Name</th>
+ <th>Version</th>
+ <th>Statements</th>
+ <th>Created</th>
+ <th>Updated</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ for _, policy := range data.Policies {
+ <tr>
+ <td>
+ <strong>{policy.Name}</strong>
+ </td>
+ <td>
+ <span class="badge bg-info">{policy.Document.Version}</span>
+ </td>
+ <td>
+ <span class="badge bg-secondary">{fmt.Sprintf("%d statements", len(policy.Document.Statement))}</span>
+ </td>
+ <td>
+ <small class="text-muted">{policy.CreatedAt.Format("2006-01-02 15:04")}</small>
+ </td>
+ <td>
+ <small class="text-muted">{policy.UpdatedAt.Format("2006-01-02 15:04")}</small>
+ </td>
+ <td>
+ <div class="btn-group btn-group-sm" role="group">
+ <button type="button" class="btn btn-outline-info view-policy-btn" title="View Policy" data-policy-name={policy.Name}>
+ <i class="fas fa-eye"></i>
+ </button>
+ <button type="button" class="btn btn-outline-primary edit-policy-btn" title="Edit Policy" data-policy-name={policy.Name}>
+ <i class="fas fa-edit"></i>
+ </button>
+ <button type="button" class="btn btn-outline-danger delete-policy-btn" title="Delete Policy" data-policy-name={policy.Name}>
+ <i class="fas fa-trash"></i>
+ </button>
+ </div>
+ </td>
+ </tr>
+ }
+ if len(data.Policies) == 0 {
+ <tr>
+ <td colspan="6" class="text-center text-muted py-4">
+ <i class="fas fa-shield-alt fa-3x mb-3 text-muted"></i>
+ <div>
+ <h5>No IAM policies found</h5>
+ <p>Create your first policy to manage access permissions.</p>
+ <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createPolicyModal">
+ <i class="fas fa-plus me-1"></i>Create Policy
+ </button>
+ </div>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Create Policy Modal -->
+ <div class="modal fade" id="createPolicyModal" tabindex="-1" aria-labelledby="createPolicyModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="createPolicyModalLabel">
+ <i class="fas fa-shield-alt me-2"></i>Create IAM Policy
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <form id="createPolicyForm">
+ <div class="mb-3">
+ <label for="policyName" class="form-label">Policy Name</label>
+ <input type="text" class="form-control" id="policyName" name="name" required placeholder="e.g., S3ReadOnlyPolicy">
+ <div class="form-text">Enter a unique name for this policy (alphanumeric and underscores only)</div>
+ </div>
+
+ <div class="mb-3">
+ <label for="policyDocument" class="form-label">Policy Document</label>
+ <textarea class="form-control" id="policyDocument" name="document" rows="15" required placeholder="Enter IAM policy JSON document..."></textarea>
+ <div class="form-text">Enter the policy document in AWS IAM JSON format</div>
+ </div>
+
+ <div class="mb-3">
+ <div class="row">
+ <div class="col-md-6">
+ <button type="button" class="btn btn-outline-info btn-sm" onclick="insertSamplePolicy()">
+ <i class="fas fa-file-alt me-1"></i>Use Sample Policy
+ </button>
+ </div>
+ <div class="col-md-6 text-end">
+ <button type="button" class="btn btn-outline-secondary btn-sm" onclick="validatePolicyDocument()">
+ <i class="fas fa-check me-1"></i>Validate JSON
+ </button>
+ </div>
+ </div>
+ </div>
+ </form>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-primary" onclick="createPolicy()">
+ <i class="fas fa-plus me-1"></i>Create Policy
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- View Policy Modal -->
+ <div class="modal fade" id="viewPolicyModal" tabindex="-1" aria-labelledby="viewPolicyModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="viewPolicyModalLabel">
+ <i class="fas fa-eye me-2"></i>View IAM Policy
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <div id="viewPolicyContent">
+ <div class="text-center">
+ <div class="spinner-border" role="status">
+ <span class="visually-hidden">Loading...</span>
+ </div>
+ <p class="mt-2">Loading policy...</p>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+ <button type="button" class="btn btn-primary" id="editFromViewBtn">
+ <i class="fas fa-edit me-1"></i>Edit Policy
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Edit Policy Modal -->
+ <div class="modal fade" id="editPolicyModal" tabindex="-1" aria-labelledby="editPolicyModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="editPolicyModalLabel">
+ <i class="fas fa-edit me-2"></i>Edit IAM Policy
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <form id="editPolicyForm">
+ <div class="mb-3">
+ <label for="editPolicyName" class="form-label">Policy Name</label>
+ <input type="text" class="form-control" id="editPolicyName" name="name" readonly>
+ <div class="form-text">Policy name cannot be changed</div>
+ </div>
+
+ <div class="mb-3">
+ <label for="editPolicyDocument" class="form-label">Policy Document</label>
+ <textarea class="form-control" id="editPolicyDocument" name="document" rows="15" required></textarea>
+ <div class="form-text">Edit the policy document in AWS IAM JSON format</div>
+ </div>
+
+ <div class="mb-3">
+ <div class="row">
+ <div class="col-md-6">
+ <button type="button" class="btn btn-outline-info btn-sm" onclick="insertSamplePolicyEdit()">
+ <i class="fas fa-file-alt me-1"></i>Reset to Sample
+ </button>
+ </div>
+ <div class="col-md-6 text-end">
+ <button type="button" class="btn btn-outline-secondary btn-sm" onclick="validateEditPolicyDocument()">
+ <i class="fas fa-check me-1"></i>Validate JSON
+ </button>
+ </div>
+ </div>
+ </div>
+ </form>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-primary" onclick="updatePolicy()">
+ <i class="fas fa-save me-1"></i>Save Changes
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- JavaScript for Policy Management -->
+ <script>
+ // Current policy being viewed/edited
+ let currentPolicy = null;
+
+ // Event listeners for policy actions
+ document.addEventListener('DOMContentLoaded', function() {
+ // View policy buttons
+ document.querySelectorAll('.view-policy-btn').forEach(button => {
+ button.addEventListener('click', function() {
+ const policyName = this.getAttribute('data-policy-name');
+ viewPolicy(policyName);
+ });
+ });
+
+ // Edit policy buttons
+ document.querySelectorAll('.edit-policy-btn').forEach(button => {
+ button.addEventListener('click', function() {
+ const policyName = this.getAttribute('data-policy-name');
+ editPolicy(policyName);
+ });
+ });
+
+ // Delete policy buttons
+ document.querySelectorAll('.delete-policy-btn').forEach(button => {
+ button.addEventListener('click', function() {
+ const policyName = this.getAttribute('data-policy-name');
+ deletePolicy(policyName);
+ });
+ });
+
+ // Edit from view button
+ document.getElementById('editFromViewBtn').addEventListener('click', function() {
+ if (currentPolicy) {
+ const viewModal = bootstrap.Modal.getInstance(document.getElementById('viewPolicyModal'));
+ if (viewModal) viewModal.hide();
+ editPolicy(currentPolicy.name);
+ }
+ });
+ });
+
+ function createPolicy() {
+ const form = document.getElementById('createPolicyForm');
+ const formData = new FormData(form);
+
+ const policyName = formData.get('name');
+ const policyDocumentText = formData.get('document');
+
+ if (!policyName || !policyDocumentText) {
+ alert('Please fill in all required fields');
+ return;
+ }
+
+ let policyDocument;
+ try {
+ policyDocument = JSON.parse(policyDocumentText);
+ } catch (e) {
+ alert('Invalid JSON in policy document: ' + e.message);
+ return;
+ }
+
+ const requestData = {
+ name: policyName,
+ document: policyDocument
+ };
+
+ fetch('/api/object-store/policies', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestData)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ alert('Policy created successfully!');
+ const modal = bootstrap.Modal.getInstance(document.getElementById('createPolicyModal'));
+ if (modal) modal.hide();
+ location.reload(); // Refresh the page to show the new policy
+ } else {
+ alert('Error creating policy: ' + (data.error || 'Unknown error'));
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ alert('Error creating policy: ' + error.message);
+ });
+ }
+
+ function viewPolicy(policyName) {
+ // Show the modal first
+ const modal = new bootstrap.Modal(document.getElementById('viewPolicyModal'));
+ modal.show();
+
+ // Reset content to loading state
+ document.getElementById('viewPolicyContent').innerHTML = `
+ <div class="text-center">
+ <div class="spinner-border" role="status">
+ <span class="visually-hidden">Loading...</span>
+ </div>
+ <p class="mt-2">Loading policy...</p>
+ </div>
+ `;
+
+ // Fetch policy data
+ fetch('/api/object-store/policies/' + encodeURIComponent(policyName))
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Policy not found');
+ }
+ return response.json();
+ })
+ .then(policy => {
+ currentPolicy = policy;
+ displayPolicyDetails(policy);
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ document.getElementById('viewPolicyContent').innerHTML = `
+ <div class="alert alert-danger" role="alert">
+ <i class="fas fa-exclamation-triangle me-2"></i>
+ Error loading policy: ${error.message}
+ </div>
+ `;
+ });
+ }
+
+ function displayPolicyDetails(policy) {
+ const content = document.getElementById('viewPolicyContent');
+
+ let statementsHtml = '';
+ if (policy.document && policy.document.Statement) {
+ statementsHtml = policy.document.Statement.map((stmt, index) => `
+ <div class="card mb-2">
+ <div class="card-header py-2">
+ <h6 class="mb-0">Statement ${index + 1}</h6>
+ </div>
+ <div class="card-body py-2">
+ <div class="row">
+ <div class="col-md-6">
+ <strong>Effect:</strong>
+ <span class="badge ${stmt.Effect === 'Allow' ? 'bg-success' : 'bg-danger'}">${stmt.Effect}</span>
+ </div>
+ <div class="col-md-6">
+ <strong>Actions:</strong> ${Array.isArray(stmt.Action) ? stmt.Action.join(', ') : stmt.Action}
+ </div>
+ </div>
+ <div class="row mt-2">
+ <div class="col-12">
+ <strong>Resources:</strong> ${Array.isArray(stmt.Resource) ? stmt.Resource.join(', ') : stmt.Resource}
+ </div>
+ </div>
+ </div>
+ </div>
+ `).join('');
+ }
+
+ content.innerHTML = `
+ <div class="row mb-3">
+ <div class="col-md-6">
+ <strong>Policy Name:</strong> ${policy.name || 'Unknown'}
+ </div>
+ <div class="col-md-6">
+ <strong>Version:</strong> <span class="badge bg-info">${policy.document?.Version || 'Unknown'}</span>
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <strong>Statements:</strong>
+ <div class="mt-2">
+ ${statementsHtml || '<p class="text-muted">No statements found</p>'}
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <strong>Raw Policy Document:</strong>
+ <pre class="bg-light p-3 border rounded mt-2"><code>${JSON.stringify(policy.document, null, 2)}</code></pre>
+ </div>
+ `;
+ }
+
+ function editPolicy(policyName) {
+ // Show the modal first
+ const modal = new bootstrap.Modal(document.getElementById('editPolicyModal'));
+ modal.show();
+
+ // Set policy name
+ document.getElementById('editPolicyName').value = policyName;
+ document.getElementById('editPolicyDocument').value = 'Loading...';
+
+ // Fetch policy data
+ fetch('/api/object-store/policies/' + encodeURIComponent(policyName))
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Policy not found');
+ }
+ return response.json();
+ })
+ .then(policy => {
+ currentPolicy = policy;
+ document.getElementById('editPolicyDocument').value = JSON.stringify(policy.document, null, 2);
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ alert('Error loading policy for editing: ' + error.message);
+ const editModal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));
+ if (editModal) editModal.hide();
+ });
+ }
+
+ function updatePolicy() {
+ const policyName = document.getElementById('editPolicyName').value;
+ const policyDocumentText = document.getElementById('editPolicyDocument').value;
+
+ if (!policyName || !policyDocumentText) {
+ alert('Please fill in all required fields');
+ return;
+ }
+
+ let policyDocument;
+ try {
+ policyDocument = JSON.parse(policyDocumentText);
+ } catch (e) {
+ alert('Invalid JSON in policy document: ' + e.message);
+ return;
+ }
+
+ const requestData = {
+ document: policyDocument
+ };
+
+ fetch('/api/object-store/policies/' + encodeURIComponent(policyName), {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestData)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ alert('Policy updated successfully!');
+ const modal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));
+ if (modal) modal.hide();
+ location.reload(); // Refresh the page to show the updated policy
+ } else {
+ alert('Error updating policy: ' + (data.error || 'Unknown error'));
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ alert('Error updating policy: ' + error.message);
+ });
+ }
+
+ function insertSamplePolicy() {
+ const samplePolicy = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": [
+ "s3:GetObject",
+ "s3:PutObject"
+ ],
+ "Resource": [
+ "arn:aws:s3:::my-bucket/*"
+ ]
+ }
+ ]
+ };
+
+ document.getElementById('policyDocument').value = JSON.stringify(samplePolicy, null, 2);
+ }
+
+ function insertSamplePolicyEdit() {
+ const samplePolicy = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": [
+ "s3:GetObject",
+ "s3:PutObject"
+ ],
+ "Resource": [
+ "arn:aws:s3:::my-bucket/*"
+ ]
+ }
+ ]
+ };
+
+ document.getElementById('editPolicyDocument').value = JSON.stringify(samplePolicy, null, 2);
+ }
+
+ function validatePolicyDocument() {
+ const policyText = document.getElementById('policyDocument').value;
+ validatePolicyJSON(policyText);
+ }
+
+ function validateEditPolicyDocument() {
+ const policyText = document.getElementById('editPolicyDocument').value;
+ validatePolicyJSON(policyText);
+ }
+
+ function validatePolicyJSON(policyText) {
+ if (!policyText) {
+ alert('Please enter a policy document first');
+ return;
+ }
+
+ try {
+ const policy = JSON.parse(policyText);
+
+ // Basic validation
+ if (!policy.Version) {
+ alert('Policy must have a Version field');
+ return;
+ }
+
+ if (!policy.Statement || !Array.isArray(policy.Statement)) {
+ alert('Policy must have a Statement array');
+ return;
+ }
+
+ alert('Policy document is valid JSON!');
+ } catch (e) {
+ alert('Invalid JSON: ' + e.message);
+ }
+ }
+
+ function deletePolicy(policyName) {
+ if (confirm('Are you sure you want to delete policy "' + policyName + '"?')) {
+ fetch('/api/object-store/policies/' + encodeURIComponent(policyName), {
+ method: 'DELETE'
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ alert('Policy deleted successfully!');
+ location.reload(); // Refresh the page
+ } else {
+ alert('Error deleting policy: ' + (data.error || 'Unknown error'));
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ alert('Error deleting policy: ' + error.message);
+ });
+ }
+ }
+ </script>
+} \ No newline at end of file
diff --git a/weed/admin/view/app/policies_templ.go b/weed/admin/view/app/policies_templ.go
new file mode 100644
index 000000000..2e005fb58
--- /dev/null
+++ b/weed/admin/view/app/policies_templ.go
@@ -0,0 +1,204 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.906
+package app
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "github.com/seaweedfs/seaweedfs/weed/admin/dash"
+)
+
+func Policies(data dash.PoliciesData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-shield-alt me-2\"></i>IAM Policies</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\"><button type=\"button\" class=\"btn btn-sm btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#createPolicyModal\"><i class=\"fas fa-plus me-1\"></i>Create Policy</button></div></div></div><div id=\"policies-content\"><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Policies</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalPolicies))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 34, Col: 74}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></div><div class=\"col-auto\"><i class=\"fas fa-shield-alt fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-success shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-success text-uppercase mb-1\">Active Policies</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalPolicies))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 54, Col: 74}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><div class=\"col-auto\"><i class=\"fas fa-check-circle fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-info shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Last Updated</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("15:04"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 74, Col: 69}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div><div class=\"col-auto\"><i class=\"fas fa-clock fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Policies Table --><div class=\"row\"><div class=\"col-12\"><div class=\"card shadow mb-4\"><div class=\"card-header py-3 d-flex flex-row align-items-center justify-content-between\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-shield-alt me-2\"></i>IAM Policies</h6><div class=\"dropdown no-arrow\"><a class=\"dropdown-toggle\" href=\"#\" role=\"button\" data-bs-toggle=\"dropdown\"><i class=\"fas fa-ellipsis-v fa-sm fa-fw text-gray-400\"></i></a><div class=\"dropdown-menu dropdown-menu-right shadow animated--fade-in\"><div class=\"dropdown-header\">Actions:</div><a class=\"dropdown-item\" href=\"#\"><i class=\"fas fa-download me-2\"></i>Export List</a></div></div></div><div class=\"card-body\"><div class=\"table-responsive\"><table class=\"table table-hover\" width=\"100%\" cellspacing=\"0\"><thead><tr><th>Policy Name</th><th>Version</th><th>Statements</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead> <tbody>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, policy := range data.Policies {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<tr><td><strong>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 123, Col: 68}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</strong></td><td><span class=\"badge bg-info\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Document.Version)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 126, Col: 100}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span></td><td><span class=\"badge bg-secondary\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d statements", len(policy.Document.Statement)))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 129, Col: 142}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></td><td><small class=\"text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(policy.CreatedAt.Format("2006-01-02 15:04"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 132, Col: 118}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</small></td><td><small class=\"text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(policy.UpdatedAt.Format("2006-01-02 15:04"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 135, Col: 118}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</small></td><td><div class=\"btn-group btn-group-sm\" role=\"group\"><button type=\"button\" class=\"btn btn-outline-info view-policy-btn\" title=\"View Policy\" data-policy-name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 139, Col: 168}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-primary edit-policy-btn\" title=\"Edit Policy\" data-policy-name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 142, Col: 171}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><i class=\"fas fa-edit\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger delete-policy-btn\" title=\"Delete Policy\" data-policy-name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 145, Col: 174}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if len(data.Policies) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<tr><td colspan=\"6\" class=\"text-center text-muted py-4\"><i class=\"fas fa-shield-alt fa-3x mb-3 text-muted\"></i><div><h5>No IAM policies found</h5><p>Create your first policy to manage access permissions.</p><button type=\"button\" class=\"btn btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#createPolicyModal\"><i class=\"fas fa-plus me-1\"></i>Create Policy</button></div></td></tr>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</tbody></table></div></div></div></div></div></div><!-- Create Policy Modal --><div class=\"modal fade\" id=\"createPolicyModal\" tabindex=\"-1\" aria-labelledby=\"createPolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createPolicyModalLabel\"><i class=\"fas fa-shield-alt me-2\"></i>Create IAM Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"createPolicyForm\"><div class=\"mb-3\"><label for=\"policyName\" class=\"form-label\">Policy Name</label> <input type=\"text\" class=\"form-control\" id=\"policyName\" name=\"name\" required placeholder=\"e.g., S3ReadOnlyPolicy\"><div class=\"form-text\">Enter a unique name for this policy (alphanumeric and underscores only)</div></div><div class=\"mb-3\"><label for=\"policyDocument\" class=\"form-label\">Policy Document</label> <textarea class=\"form-control\" id=\"policyDocument\" name=\"document\" rows=\"15\" required placeholder=\"Enter IAM policy JSON document...\"></textarea><div class=\"form-text\">Enter the policy document in AWS IAM JSON format</div></div><div class=\"mb-3\"><div class=\"row\"><div class=\"col-md-6\"><button type=\"button\" class=\"btn btn-outline-info btn-sm\" onclick=\"insertSamplePolicy()\"><i class=\"fas fa-file-alt me-1\"></i>Use Sample Policy</button></div><div class=\"col-md-6 text-end\"><button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" onclick=\"validatePolicyDocument()\"><i class=\"fas fa-check me-1\"></i>Validate JSON</button></div></div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"createPolicy()\"><i class=\"fas fa-plus me-1\"></i>Create Policy</button></div></div></div></div><!-- View Policy Modal --><div class=\"modal fade\" id=\"viewPolicyModal\" tabindex=\"-1\" aria-labelledby=\"viewPolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"viewPolicyModalLabel\"><i class=\"fas fa-eye me-2\"></i>View IAM Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><div id=\"viewPolicyContent\"><div class=\"text-center\"><div class=\"spinner-border\" role=\"status\"><span class=\"visually-hidden\">Loading...</span></div><p class=\"mt-2\">Loading policy...</p></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button> <button type=\"button\" class=\"btn btn-primary\" id=\"editFromViewBtn\"><i class=\"fas fa-edit me-1\"></i>Edit Policy</button></div></div></div></div><!-- Edit Policy Modal --><div class=\"modal fade\" id=\"editPolicyModal\" tabindex=\"-1\" aria-labelledby=\"editPolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"editPolicyModalLabel\"><i class=\"fas fa-edit me-2\"></i>Edit IAM Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"editPolicyForm\"><div class=\"mb-3\"><label for=\"editPolicyName\" class=\"form-label\">Policy Name</label> <input type=\"text\" class=\"form-control\" id=\"editPolicyName\" name=\"name\" readonly><div class=\"form-text\">Policy name cannot be changed</div></div><div class=\"mb-3\"><label for=\"editPolicyDocument\" class=\"form-label\">Policy Document</label> <textarea class=\"form-control\" id=\"editPolicyDocument\" name=\"document\" rows=\"15\" required></textarea><div class=\"form-text\">Edit the policy document in AWS IAM JSON format</div></div><div class=\"mb-3\"><div class=\"row\"><div class=\"col-md-6\"><button type=\"button\" class=\"btn btn-outline-info btn-sm\" onclick=\"insertSamplePolicyEdit()\"><i class=\"fas fa-file-alt me-1\"></i>Reset to Sample</button></div><div class=\"col-md-6 text-end\"><button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" onclick=\"validateEditPolicyDocument()\"><i class=\"fas fa-check me-1\"></i>Validate JSON</button></div></div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"updatePolicy()\"><i class=\"fas fa-save me-1\"></i>Save Changes</button></div></div></div></div><!-- JavaScript for Policy Management --><script>\n // Current policy being viewed/edited\n let currentPolicy = null;\n \n // Event listeners for policy actions\n document.addEventListener('DOMContentLoaded', function() {\n // View policy buttons\n document.querySelectorAll('.view-policy-btn').forEach(button => {\n button.addEventListener('click', function() {\n const policyName = this.getAttribute('data-policy-name');\n viewPolicy(policyName);\n });\n });\n \n // Edit policy buttons\n document.querySelectorAll('.edit-policy-btn').forEach(button => {\n button.addEventListener('click', function() {\n const policyName = this.getAttribute('data-policy-name');\n editPolicy(policyName);\n });\n });\n \n // Delete policy buttons\n document.querySelectorAll('.delete-policy-btn').forEach(button => {\n button.addEventListener('click', function() {\n const policyName = this.getAttribute('data-policy-name');\n deletePolicy(policyName);\n });\n });\n \n // Edit from view button\n document.getElementById('editFromViewBtn').addEventListener('click', function() {\n if (currentPolicy) {\n const viewModal = bootstrap.Modal.getInstance(document.getElementById('viewPolicyModal'));\n if (viewModal) viewModal.hide();\n editPolicy(currentPolicy.name);\n }\n });\n });\n \n function createPolicy() {\n const form = document.getElementById('createPolicyForm');\n const formData = new FormData(form);\n \n const policyName = formData.get('name');\n const policyDocumentText = formData.get('document');\n \n if (!policyName || !policyDocumentText) {\n alert('Please fill in all required fields');\n return;\n }\n \n let policyDocument;\n try {\n policyDocument = JSON.parse(policyDocumentText);\n } catch (e) {\n alert('Invalid JSON in policy document: ' + e.message);\n return;\n }\n \n const requestData = {\n name: policyName,\n document: policyDocument\n };\n \n fetch('/api/object-store/policies', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(requestData)\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Policy created successfully!');\n const modal = bootstrap.Modal.getInstance(document.getElementById('createPolicyModal'));\n if (modal) modal.hide();\n location.reload(); // Refresh the page to show the new policy\n } else {\n alert('Error creating policy: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error creating policy: ' + error.message);\n });\n }\n \n function viewPolicy(policyName) {\n // Show the modal first\n const modal = new bootstrap.Modal(document.getElementById('viewPolicyModal'));\n modal.show();\n \n // Reset content to loading state\n document.getElementById('viewPolicyContent').innerHTML = `\n <div class=\"text-center\">\n <div class=\"spinner-border\" role=\"status\">\n <span class=\"visually-hidden\">Loading...</span>\n </div>\n <p class=\"mt-2\">Loading policy...</p>\n </div>\n `;\n \n // Fetch policy data\n fetch('/api/object-store/policies/' + encodeURIComponent(policyName))\n .then(response => {\n if (!response.ok) {\n throw new Error('Policy not found');\n }\n return response.json();\n })\n .then(policy => {\n currentPolicy = policy;\n displayPolicyDetails(policy);\n })\n .catch(error => {\n console.error('Error:', error);\n document.getElementById('viewPolicyContent').innerHTML = `\n <div class=\"alert alert-danger\" role=\"alert\">\n <i class=\"fas fa-exclamation-triangle me-2\"></i>\n Error loading policy: ${error.message}\n </div>\n `;\n });\n }\n \n function displayPolicyDetails(policy) {\n const content = document.getElementById('viewPolicyContent');\n \n let statementsHtml = '';\n if (policy.document && policy.document.Statement) {\n statementsHtml = policy.document.Statement.map((stmt, index) => `\n <div class=\"card mb-2\">\n <div class=\"card-header py-2\">\n <h6 class=\"mb-0\">Statement ${index + 1}</h6>\n </div>\n <div class=\"card-body py-2\">\n <div class=\"row\">\n <div class=\"col-md-6\">\n <strong>Effect:</strong> \n <span class=\"badge ${stmt.Effect === 'Allow' ? 'bg-success' : 'bg-danger'}\">${stmt.Effect}</span>\n </div>\n <div class=\"col-md-6\">\n <strong>Actions:</strong> ${Array.isArray(stmt.Action) ? stmt.Action.join(', ') : stmt.Action}\n </div>\n </div>\n <div class=\"row mt-2\">\n <div class=\"col-12\">\n <strong>Resources:</strong> ${Array.isArray(stmt.Resource) ? stmt.Resource.join(', ') : stmt.Resource}\n </div>\n </div>\n </div>\n </div>\n `).join('');\n }\n \n content.innerHTML = `\n <div class=\"row mb-3\">\n <div class=\"col-md-6\">\n <strong>Policy Name:</strong> ${policy.name || 'Unknown'}\n </div>\n <div class=\"col-md-6\">\n <strong>Version:</strong> <span class=\"badge bg-info\">${policy.document?.Version || 'Unknown'}</span>\n </div>\n </div>\n \n <div class=\"mb-3\">\n <strong>Statements:</strong>\n <div class=\"mt-2\">\n ${statementsHtml || '<p class=\"text-muted\">No statements found</p>'}\n </div>\n </div>\n \n <div class=\"mb-3\">\n <strong>Raw Policy Document:</strong>\n <pre class=\"bg-light p-3 border rounded mt-2\"><code>${JSON.stringify(policy.document, null, 2)}</code></pre>\n </div>\n `;\n }\n \n function editPolicy(policyName) {\n // Show the modal first\n const modal = new bootstrap.Modal(document.getElementById('editPolicyModal'));\n modal.show();\n \n // Set policy name\n document.getElementById('editPolicyName').value = policyName;\n document.getElementById('editPolicyDocument').value = 'Loading...';\n \n // Fetch policy data\n fetch('/api/object-store/policies/' + encodeURIComponent(policyName))\n .then(response => {\n if (!response.ok) {\n throw new Error('Policy not found');\n }\n return response.json();\n })\n .then(policy => {\n currentPolicy = policy;\n document.getElementById('editPolicyDocument').value = JSON.stringify(policy.document, null, 2);\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error loading policy for editing: ' + error.message);\n const editModal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));\n if (editModal) editModal.hide();\n });\n }\n \n function updatePolicy() {\n const policyName = document.getElementById('editPolicyName').value;\n const policyDocumentText = document.getElementById('editPolicyDocument').value;\n \n if (!policyName || !policyDocumentText) {\n alert('Please fill in all required fields');\n return;\n }\n \n let policyDocument;\n try {\n policyDocument = JSON.parse(policyDocumentText);\n } catch (e) {\n alert('Invalid JSON in policy document: ' + e.message);\n return;\n }\n \n const requestData = {\n document: policyDocument\n };\n \n fetch('/api/object-store/policies/' + encodeURIComponent(policyName), {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(requestData)\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Policy updated successfully!');\n const modal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));\n if (modal) modal.hide();\n location.reload(); // Refresh the page to show the updated policy\n } else {\n alert('Error updating policy: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error updating policy: ' + error.message);\n });\n }\n \n function insertSamplePolicy() {\n const samplePolicy = {\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"s3:GetObject\",\n \"s3:PutObject\"\n ],\n \"Resource\": [\n \"arn:aws:s3:::my-bucket/*\"\n ]\n }\n ]\n };\n \n document.getElementById('policyDocument').value = JSON.stringify(samplePolicy, null, 2);\n }\n \n function insertSamplePolicyEdit() {\n const samplePolicy = {\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"s3:GetObject\",\n \"s3:PutObject\"\n ],\n \"Resource\": [\n \"arn:aws:s3:::my-bucket/*\"\n ]\n }\n ]\n };\n \n document.getElementById('editPolicyDocument').value = JSON.stringify(samplePolicy, null, 2);\n }\n \n function validatePolicyDocument() {\n const policyText = document.getElementById('policyDocument').value;\n validatePolicyJSON(policyText);\n }\n \n function validateEditPolicyDocument() {\n const policyText = document.getElementById('editPolicyDocument').value;\n validatePolicyJSON(policyText);\n }\n \n function validatePolicyJSON(policyText) {\n if (!policyText) {\n alert('Please enter a policy document first');\n return;\n }\n \n try {\n const policy = JSON.parse(policyText);\n \n // Basic validation\n if (!policy.Version) {\n alert('Policy must have a Version field');\n return;\n }\n \n if (!policy.Statement || !Array.isArray(policy.Statement)) {\n alert('Policy must have a Statement array');\n return;\n }\n \n alert('Policy document is valid JSON!');\n } catch (e) {\n alert('Invalid JSON: ' + e.message);\n }\n }\n \n function deletePolicy(policyName) {\n if (confirm('Are you sure you want to delete policy \"' + policyName + '\"?')) {\n fetch('/api/object-store/policies/' + encodeURIComponent(policyName), {\n method: 'DELETE'\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Policy deleted successfully!');\n location.reload(); // Refresh the page\n } else {\n alert('Error deleting policy: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error deleting policy: ' + error.message);\n });\n }\n }\n </script>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/weed/admin/view/app/s3_buckets.templ b/weed/admin/view/app/s3_buckets.templ
index d6625f5e8..1afafb294 100644
--- a/weed/admin/view/app/s3_buckets.templ
+++ b/weed/admin/view/app/s3_buckets.templ
@@ -187,11 +187,12 @@ templ S3Buckets(data dash.S3BucketsData) {
title="Browse Files">
<i class="fas fa-folder-open"></i>
</a>
- <a href={templ.SafeURL(fmt.Sprintf("/s3/buckets/%s", bucket.Name))}
- class="btn btn-outline-primary btn-sm"
- title="View Details">
+ <button type="button"
+ class="btn btn-outline-primary btn-sm view-details-btn"
+ data-bucket-name={bucket.Name}
+ title="View Details">
<i class="fas fa-eye"></i>
- </a>
+ </button>
<button type="button"
class="btn btn-outline-warning btn-sm quota-btn"
data-bucket-name={bucket.Name}
@@ -442,6 +443,33 @@ templ S3Buckets(data dash.S3BucketsData) {
</div>
</div>
+ <!-- Bucket Details Modal -->
+ <div class="modal fade" id="bucketDetailsModal" tabindex="-1" aria-labelledby="bucketDetailsModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="bucketDetailsModalLabel">
+ <i class="fas fa-cube me-2"></i>Bucket Details
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <div id="bucketDetailsContent">
+ <div class="text-center py-4">
+ <div class="spinner-border text-primary" role="status">
+ <span class="visually-hidden">Loading...</span>
+ </div>
+ <div class="mt-2">Loading bucket details...</div>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
<!-- JavaScript for bucket management -->
<script>
document.addEventListener('DOMContentLoaded', function() {
@@ -504,7 +532,12 @@ templ S3Buckets(data dash.S3BucketsData) {
alert('Error creating bucket: ' + data.error);
} else {
alert('Bucket created successfully!');
- location.reload();
+ // Properly close the modal before reloading
+ const createModal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal'));
+ if (createModal) {
+ createModal.hide();
+ }
+ setTimeout(() => location.reload(), 500);
}
})
.catch(error => {
@@ -514,16 +547,41 @@ templ S3Buckets(data dash.S3BucketsData) {
});
// Handle delete bucket
+ let deleteModalInstance = null;
document.querySelectorAll('.delete-bucket-btn').forEach(button => {
button.addEventListener('click', function() {
const bucketName = this.dataset.bucketName;
document.getElementById('deleteBucketName').textContent = bucketName;
window.currentBucketToDelete = bucketName;
- new bootstrap.Modal(document.getElementById('deleteBucketModal')).show();
+
+ // Dispose of existing modal instance if it exists
+ if (deleteModalInstance) {
+ deleteModalInstance.dispose();
+ }
+
+ // Create new modal instance
+ deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));
+ deleteModalInstance.show();
+ });
+ });
+
+ // Add event listener to properly dispose of delete modal when hidden
+ document.getElementById('deleteBucketModal').addEventListener('hidden.bs.modal', function() {
+ if (deleteModalInstance) {
+ deleteModalInstance.dispose();
+ deleteModalInstance = null;
+ }
+ // Force remove any remaining backdrops
+ document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
+ backdrop.remove();
});
+ // Ensure body classes are removed
+ document.body.classList.remove('modal-open');
+ document.body.style.removeProperty('padding-right');
});
// Handle quota management
+ let quotaModalInstance = null;
document.querySelectorAll('.quota-btn').forEach(button => {
button.addEventListener('click', function() {
const bucketName = this.dataset.bucketName;
@@ -538,10 +596,33 @@ templ S3Buckets(data dash.S3BucketsData) {
document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';
window.currentBucketToUpdate = bucketName;
- new bootstrap.Modal(document.getElementById('manageQuotaModal')).show();
+
+ // Dispose of existing modal instance if it exists
+ if (quotaModalInstance) {
+ quotaModalInstance.dispose();
+ }
+
+ // Create new modal instance
+ quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));
+ quotaModalInstance.show();
});
});
+ // Add event listener to properly dispose of quota modal when hidden
+ document.getElementById('manageQuotaModal').addEventListener('hidden.bs.modal', function() {
+ if (quotaModalInstance) {
+ quotaModalInstance.dispose();
+ quotaModalInstance = null;
+ }
+ // Force remove any remaining backdrops
+ document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
+ backdrop.remove();
+ });
+ // Ensure body classes are removed
+ document.body.classList.remove('modal-open');
+ document.body.style.removeProperty('padding-right');
+ });
+
// Handle quota form submission
document.getElementById('quotaForm').addEventListener('submit', function(e) {
e.preventDefault();
@@ -567,7 +648,11 @@ templ S3Buckets(data dash.S3BucketsData) {
alert('Error updating quota: ' + data.error);
} else {
alert('Quota updated successfully!');
- location.reload();
+ // Properly close the modal before reloading
+ if (quotaModalInstance) {
+ quotaModalInstance.hide();
+ }
+ setTimeout(() => location.reload(), 500);
}
})
.catch(error => {
@@ -580,6 +665,74 @@ templ S3Buckets(data dash.S3BucketsData) {
document.getElementById('quotaEnabled').addEventListener('change', function() {
document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';
});
+
+ // Handle view details button
+ let detailsModalInstance = null;
+ document.querySelectorAll('.view-details-btn').forEach(button => {
+ button.addEventListener('click', function() {
+ const bucketName = this.dataset.bucketName;
+
+ // Update modal title
+ document.getElementById('bucketDetailsModalLabel').innerHTML =
+ '<i class="fas fa-cube me-2"></i>Bucket Details - ' + bucketName;
+
+ // Show loading spinner
+ document.getElementById('bucketDetailsContent').innerHTML =
+ '<div class="text-center py-4">' +
+ '<div class="spinner-border text-primary" role="status">' +
+ '<span class="visually-hidden">Loading...</span>' +
+ '</div>' +
+ '<div class="mt-2">Loading bucket details...</div>' +
+ '</div>';
+
+ // Dispose of existing modal instance if it exists
+ if (detailsModalInstance) {
+ detailsModalInstance.dispose();
+ }
+
+ // Create new modal instance
+ detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));
+ detailsModalInstance.show();
+
+ // Fetch bucket details
+ fetch('/api/s3/buckets/' + bucketName)
+ .then(response => response.json())
+ .then(data => {
+ if (data.error) {
+ document.getElementById('bucketDetailsContent').innerHTML =
+ '<div class="alert alert-danger">' +
+ '<i class="fas fa-exclamation-triangle me-2"></i>' +
+ 'Error loading bucket details: ' + data.error +
+ '</div>';
+ } else {
+ displayBucketDetails(data);
+ }
+ })
+ .catch(error => {
+ console.error('Error fetching bucket details:', error);
+ document.getElementById('bucketDetailsContent').innerHTML =
+ '<div class="alert alert-danger">' +
+ '<i class="fas fa-exclamation-triangle me-2"></i>' +
+ 'Error loading bucket details: ' + error.message +
+ '</div>';
+ });
+ });
+ });
+
+ // Add event listener to properly dispose of details modal when hidden
+ document.getElementById('bucketDetailsModal').addEventListener('hidden.bs.modal', function() {
+ if (detailsModalInstance) {
+ detailsModalInstance.dispose();
+ detailsModalInstance = null;
+ }
+ // Force remove any remaining backdrops
+ document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
+ backdrop.remove();
+ });
+ // Ensure body classes are removed
+ document.body.classList.remove('modal-open');
+ document.body.style.removeProperty('padding-right');
+ });
});
function deleteBucket() {
@@ -595,7 +748,11 @@ templ S3Buckets(data dash.S3BucketsData) {
alert('Error deleting bucket: ' + data.error);
} else {
alert('Bucket deleted successfully!');
- location.reload();
+ // Properly close the modal before reloading
+ if (deleteModalInstance) {
+ deleteModalInstance.hide();
+ }
+ setTimeout(() => location.reload(), 500);
}
})
.catch(error => {
@@ -604,6 +761,128 @@ templ S3Buckets(data dash.S3BucketsData) {
});
}
+ function displayBucketDetails(data) {
+ const bucket = data.bucket;
+ const objects = data.objects || [];
+
+ // Helper function to format bytes
+ function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ // Helper function to format date
+ function formatDate(dateString) {
+ const date = new Date(dateString);
+ return date.toLocaleString();
+ }
+
+ // Generate objects table
+ let objectsTable = '';
+ if (objects.length > 0) {
+ objectsTable = '<div class="table-responsive">' +
+ '<table class="table table-sm table-striped">' +
+ '<thead>' +
+ '<tr>' +
+ '<th>Object Key</th>' +
+ '<th>Size</th>' +
+ '<th>Last Modified</th>' +
+ '<th>Storage Class</th>' +
+ '</tr>' +
+ '</thead>' +
+ '<tbody>' +
+ objects.map(obj =>
+ '<tr>' +
+ '<td><i class="fas fa-file me-1"></i>' + obj.key + '</td>' +
+ '<td>' + formatBytes(obj.size) + '</td>' +
+ '<td>' + formatDate(obj.last_modified) + '</td>' +
+ '<td><span class="badge bg-primary">' + obj.storage_class + '</span></td>' +
+ '</tr>'
+ ).join('') +
+ '</tbody>' +
+ '</table>' +
+ '</div>';
+ } else {
+ objectsTable = '<div class="text-center py-4 text-muted">' +
+ '<i class="fas fa-file fa-3x mb-3"></i>' +
+ '<div>No objects found in this bucket</div>' +
+ '</div>';
+ }
+
+ const content = '<div class="row">' +
+ '<div class="col-md-6">' +
+ '<h6><i class="fas fa-info-circle me-2"></i>Bucket Information</h6>' +
+ '<table class="table table-sm">' +
+ '<tr>' +
+ '<td><strong>Name:</strong></td>' +
+ '<td>' + bucket.name + '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Created:</strong></td>' +
+ '<td>' + formatDate(bucket.created_at) + '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Last Modified:</strong></td>' +
+ '<td>' + formatDate(bucket.last_modified) + '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Total Size:</strong></td>' +
+ '<td>' + formatBytes(bucket.size) + '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Object Count:</strong></td>' +
+ '<td>' + bucket.object_count + '</td>' +
+ '</tr>' +
+ '</table>' +
+ '</div>' +
+ '<div class="col-md-6">' +
+ '<h6><i class="fas fa-cogs me-2"></i>Configuration</h6>' +
+ '<table class="table table-sm">' +
+ '<tr>' +
+ '<td><strong>Quota:</strong></td>' +
+ '<td>' +
+ (bucket.quota_enabled ?
+ '<span class="badge bg-success">' + formatBytes(bucket.quota) + '</span>' :
+ '<span class="badge bg-secondary">Disabled</span>'
+ ) +
+ '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Versioning:</strong></td>' +
+ '<td>' +
+ (bucket.versioning_enabled ?
+ '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Enabled</span>' :
+ '<span class="badge bg-secondary"><i class="fas fa-times me-1"></i>Disabled</span>'
+ ) +
+ '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Object Lock:</strong></td>' +
+ '<td>' +
+ (bucket.object_lock_enabled ?
+ '<span class="badge bg-warning"><i class="fas fa-lock me-1"></i>Enabled</span>' +
+ '<br><small class="text-muted">' + bucket.object_lock_mode + ' • ' + bucket.object_lock_duration + ' days</small>' :
+ '<span class="badge bg-secondary"><i class="fas fa-unlock me-1"></i>Disabled</span>'
+ ) +
+ '</td>' +
+ '</tr>' +
+ '</table>' +
+ '</div>' +
+ '</div>' +
+ '<hr>' +
+ '<div class="row">' +
+ '<div class="col-12">' +
+ '<h6><i class="fas fa-list me-2"></i>Objects (' + objects.length + ')</h6>' +
+ objectsTable +
+ '</div>' +
+ '</div>';
+
+ document.getElementById('bucketDetailsContent').innerHTML = content;
+ }
+
function exportBucketList() {
// Simple CSV export
const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {
@@ -624,7 +903,7 @@ templ S3Buckets(data dash.S3BucketsData) {
const csvContent = "data:text/csv;charset=utf-8," +
"Name,Created,Objects,Size,Quota,Versioning,Object Lock\n" +
- buckets.map(b => `"${b.name}","${b.created}","${b.objects}","${b.size}","${b.quota}","${b.versioning}","${b.objectLock}"`).join("\n");
+ buckets.map(b => '"' + b.name + '","' + b.created + '","' + b.objects + '","' + b.size + '","' + b.quota + '","' + b.versioning + '","' + b.objectLock + '"').join("\n");
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
diff --git a/weed/admin/view/app/s3_buckets_templ.go b/weed/admin/view/app/s3_buckets_templ.go
index 0912a51c5..6edb5d371 100644
--- a/weed/admin/view/app/s3_buckets_templ.go
+++ b/weed/admin/view/app/s3_buckets_templ.go
@@ -290,27 +290,27 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"btn btn-outline-success btn-sm\" title=\"Browse Files\"><i class=\"fas fa-folder-open\"></i></a> <a href=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"btn btn-outline-success btn-sm\" title=\"Browse Files\"><i class=\"fas fa-folder-open\"></i></a> <button type=\"button\" class=\"btn btn-outline-primary btn-sm view-details-btn\" data-bucket-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var17 templ.SafeURL
- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/s3/buckets/%s", bucket.Name)))
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 190, Col: 118}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 192, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></a> <button type=\"button\" class=\"btn btn-outline-warning btn-sm quota-btn\" data-bucket-name=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-warning btn-sm quota-btn\" data-bucket-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 197, Col: 89}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 198, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -323,7 +323,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", getQuotaInMB(bucket.Quota)))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 198, Col: 125}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 199, Col: 125}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -336,7 +336,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", bucket.QuotaEnabled))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 199, Col: 118}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 200, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -349,7 +349,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 205, Col: 89}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 206, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
@@ -373,13 +373,13 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 242, Col: 81}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 243, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</small></div></div></div><!-- Create Bucket Modal --><div class=\"modal fade\" id=\"createBucketModal\" tabindex=\"-1\" aria-labelledby=\"createBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createBucketModalLabel\"><i class=\"fas fa-plus me-2\"></i>Create New S3 Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"createBucketForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label for=\"bucketName\" class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"bucketName\" name=\"name\" placeholder=\"my-bucket-name\" required pattern=\"[a-z0-9.-]+\" title=\"Bucket name must contain only lowercase letters, numbers, dots, and hyphens\"><div class=\"form-text\">Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableQuota\" name=\"quota_enabled\"> <label class=\"form-check-label\" for=\"enableQuota\">Enable Storage Quota</label></div></div><div class=\"mb-3\" id=\"quotaSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-8\"><label for=\"quotaSize\" class=\"form-label\">Quota Size</label> <input type=\"number\" class=\"form-control\" id=\"quotaSize\" name=\"quota_size\" placeholder=\"1024\" min=\"1\" step=\"1\"></div><div class=\"col-md-4\"><label for=\"quotaUnit\" class=\"form-label\">Unit</label> <select class=\"form-select\" id=\"quotaUnit\" name=\"quota_unit\"><option value=\"MB\" selected>MB</option> <option value=\"GB\">GB</option> <option value=\"TB\">TB</option></select></div></div><div class=\"form-text\">Set the maximum storage size for this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableVersioning\" name=\"versioning_enabled\"> <label class=\"form-check-label\" for=\"enableVersioning\">Enable Object Versioning</label></div><div class=\"form-text\">Keep multiple versions of objects in this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableObjectLock\" name=\"object_lock_enabled\"> <label class=\"form-check-label\" for=\"enableObjectLock\">Enable Object Lock</label></div><div class=\"form-text\">Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.</div></div><div class=\"mb-3\" id=\"objectLockSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-6\"><label for=\"objectLockMode\" class=\"form-label\">Object Lock Mode</label> <select class=\"form-select\" id=\"objectLockMode\" name=\"object_lock_mode\"><option value=\"GOVERNANCE\" selected>Governance</option> <option value=\"COMPLIANCE\">Compliance</option></select><div class=\"form-text\">Governance allows override with special permissions, Compliance is immutable.</div></div><div class=\"col-md-6\"><label for=\"objectLockDuration\" class=\"form-label\">Default Retention (days)</label> <input type=\"number\" class=\"form-control\" id=\"objectLockDuration\" name=\"object_lock_duration\" placeholder=\"30\" min=\"1\" max=\"36500\" step=\"1\"><div class=\"form-text\">Default retention period for new objects (1-36500 days).</div></div></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-plus me-1\"></i>Create Bucket</button></div></form></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteBucketModal\" tabindex=\"-1\" aria-labelledby=\"deleteBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"deleteBucketModalLabel\"><i class=\"fas fa-exclamation-triangle me-2 text-warning\"></i>Delete Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the bucket <strong id=\"deleteBucketName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-exclamation-triangle me-2\"></i> <strong>Warning:</strong> This action cannot be undone. All objects in the bucket will be permanently deleted.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" onclick=\"deleteBucket()\"><i class=\"fas fa-trash me-1\"></i>Delete Bucket</button></div></div></div></div><!-- Manage Quota Modal --><div class=\"modal fade\" id=\"manageQuotaModal\" tabindex=\"-1\" aria-labelledby=\"manageQuotaModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"manageQuotaModalLabel\"><i class=\"fas fa-tachometer-alt me-2\"></i>Manage Bucket Quota</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"quotaForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"quotaBucketName\" readonly></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"quotaEnabled\" name=\"quota_enabled\"> <label class=\"form-check-label\" for=\"quotaEnabled\">Enable Storage Quota</label></div></div><div class=\"mb-3\" id=\"quotaSizeSettings\"><div class=\"row\"><div class=\"col-md-8\"><label for=\"quotaSizeMB\" class=\"form-label\">Quota Size</label> <input type=\"number\" class=\"form-control\" id=\"quotaSizeMB\" name=\"quota_size\" placeholder=\"1024\" min=\"0\" step=\"1\"></div><div class=\"col-md-4\"><label for=\"quotaUnitMB\" class=\"form-label\">Unit</label> <select class=\"form-select\" id=\"quotaUnitMB\" name=\"quota_unit\"><option value=\"MB\" selected>MB</option> <option value=\"GB\">GB</option> <option value=\"TB\">TB</option></select></div></div><div class=\"form-text\">Set the maximum storage size for this bucket. Set to 0 to remove quota.</div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-warning\"><i class=\"fas fa-save me-1\"></i>Update Quota</button></div></form></div></div></div><!-- JavaScript for bucket management --><script>\n document.addEventListener('DOMContentLoaded', function() {\n const quotaCheckbox = document.getElementById('enableQuota');\n const quotaSettings = document.getElementById('quotaSettings');\n const versioningCheckbox = document.getElementById('enableVersioning');\n const objectLockCheckbox = document.getElementById('enableObjectLock');\n const objectLockSettings = document.getElementById('objectLockSettings');\n const createBucketForm = document.getElementById('createBucketForm');\n\n // Toggle quota settings\n quotaCheckbox.addEventListener('change', function() {\n quotaSettings.style.display = this.checked ? 'block' : 'none';\n });\n\n // Toggle object lock settings and automatically enable versioning\n objectLockCheckbox.addEventListener('change', function() {\n objectLockSettings.style.display = this.checked ? 'block' : 'none';\n if (this.checked) {\n versioningCheckbox.checked = true;\n versioningCheckbox.disabled = true;\n } else {\n versioningCheckbox.disabled = false;\n }\n });\n\n // Handle form submission\n createBucketForm.addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const data = {\n name: formData.get('name'),\n region: formData.get('region') || '',\n quota_size: quotaCheckbox.checked ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: quotaCheckbox.checked,\n versioning_enabled: versioningCheckbox.checked,\n object_lock_enabled: objectLockCheckbox.checked,\n object_lock_mode: formData.get('object_lock_mode') || 'GOVERNANCE',\n object_lock_duration: objectLockCheckbox.checked ? parseInt(formData.get('object_lock_duration')) || 30 : 0\n };\n\n // Validate object lock settings\n if (data.object_lock_enabled && data.object_lock_duration <= 0) {\n alert('Please enter a valid retention duration for object lock.');\n return;\n }\n\n fetch('/api/s3/buckets', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error creating bucket: ' + data.error);\n } else {\n alert('Bucket created successfully!');\n location.reload();\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error creating bucket: ' + error.message);\n });\n });\n\n // Handle delete bucket\n document.querySelectorAll('.delete-bucket-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n document.getElementById('deleteBucketName').textContent = bucketName;\n window.currentBucketToDelete = bucketName;\n new bootstrap.Modal(document.getElementById('deleteBucketModal')).show();\n });\n });\n\n // Handle quota management\n document.querySelectorAll('.quota-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n const currentQuota = parseInt(this.dataset.currentQuota);\n const quotaEnabled = this.dataset.quotaEnabled === 'true';\n \n document.getElementById('quotaBucketName').value = bucketName;\n document.getElementById('quotaEnabled').checked = quotaEnabled;\n document.getElementById('quotaSizeMB').value = currentQuota;\n \n // Toggle quota size settings\n document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';\n \n window.currentBucketToUpdate = bucketName;\n new bootstrap.Modal(document.getElementById('manageQuotaModal')).show();\n });\n });\n\n // Handle quota form submission\n document.getElementById('quotaForm').addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const enabled = document.getElementById('quotaEnabled').checked;\n const data = {\n quota_size: enabled ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: enabled\n };\n\n fetch(`/api/s3/buckets/${window.currentBucketToUpdate}/quota`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error updating quota: ' + data.error);\n } else {\n alert('Quota updated successfully!');\n location.reload();\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error updating quota: ' + error.message);\n });\n });\n\n // Handle quota enabled checkbox\n document.getElementById('quotaEnabled').addEventListener('change', function() {\n document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';\n });\n });\n\n function deleteBucket() {\n const bucketName = window.currentBucketToDelete;\n if (!bucketName) return;\n\n fetch(`/api/s3/buckets/${bucketName}`, {\n method: 'DELETE'\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error deleting bucket: ' + data.error);\n } else {\n alert('Bucket deleted successfully!');\n location.reload();\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error deleting bucket: ' + error.message);\n });\n }\n\n function exportBucketList() {\n // Simple CSV export\n const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n name: cells[0].textContent.trim(),\n created: cells[1].textContent.trim(),\n objects: cells[2].textContent.trim(),\n size: cells[3].textContent.trim(),\n quota: cells[4].textContent.trim(),\n versioning: cells[5].textContent.trim(),\n objectLock: cells[6].textContent.trim()\n };\n }\n return null;\n }).filter(bucket => bucket !== null);\n\n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Name,Created,Objects,Size,Quota,Versioning,Object Lock\\n\" +\n buckets.map(b => `\"${b.name}\",\"${b.created}\",\"${b.objects}\",\"${b.size}\",\"${b.quota}\",\"${b.versioning}\",\"${b.objectLock}\"`).join(\"\\n\");\n\n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"buckets.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</small></div></div></div><!-- Create Bucket Modal --><div class=\"modal fade\" id=\"createBucketModal\" tabindex=\"-1\" aria-labelledby=\"createBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createBucketModalLabel\"><i class=\"fas fa-plus me-2\"></i>Create New S3 Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"createBucketForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label for=\"bucketName\" class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"bucketName\" name=\"name\" placeholder=\"my-bucket-name\" required pattern=\"[a-z0-9.-]+\" title=\"Bucket name must contain only lowercase letters, numbers, dots, and hyphens\"><div class=\"form-text\">Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableQuota\" name=\"quota_enabled\"> <label class=\"form-check-label\" for=\"enableQuota\">Enable Storage Quota</label></div></div><div class=\"mb-3\" id=\"quotaSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-8\"><label for=\"quotaSize\" class=\"form-label\">Quota Size</label> <input type=\"number\" class=\"form-control\" id=\"quotaSize\" name=\"quota_size\" placeholder=\"1024\" min=\"1\" step=\"1\"></div><div class=\"col-md-4\"><label for=\"quotaUnit\" class=\"form-label\">Unit</label> <select class=\"form-select\" id=\"quotaUnit\" name=\"quota_unit\"><option value=\"MB\" selected>MB</option> <option value=\"GB\">GB</option> <option value=\"TB\">TB</option></select></div></div><div class=\"form-text\">Set the maximum storage size for this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableVersioning\" name=\"versioning_enabled\"> <label class=\"form-check-label\" for=\"enableVersioning\">Enable Object Versioning</label></div><div class=\"form-text\">Keep multiple versions of objects in this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableObjectLock\" name=\"object_lock_enabled\"> <label class=\"form-check-label\" for=\"enableObjectLock\">Enable Object Lock</label></div><div class=\"form-text\">Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.</div></div><div class=\"mb-3\" id=\"objectLockSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-6\"><label for=\"objectLockMode\" class=\"form-label\">Object Lock Mode</label> <select class=\"form-select\" id=\"objectLockMode\" name=\"object_lock_mode\"><option value=\"GOVERNANCE\" selected>Governance</option> <option value=\"COMPLIANCE\">Compliance</option></select><div class=\"form-text\">Governance allows override with special permissions, Compliance is immutable.</div></div><div class=\"col-md-6\"><label for=\"objectLockDuration\" class=\"form-label\">Default Retention (days)</label> <input type=\"number\" class=\"form-control\" id=\"objectLockDuration\" name=\"object_lock_duration\" placeholder=\"30\" min=\"1\" max=\"36500\" step=\"1\"><div class=\"form-text\">Default retention period for new objects (1-36500 days).</div></div></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-plus me-1\"></i>Create Bucket</button></div></form></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteBucketModal\" tabindex=\"-1\" aria-labelledby=\"deleteBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"deleteBucketModalLabel\"><i class=\"fas fa-exclamation-triangle me-2 text-warning\"></i>Delete Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the bucket <strong id=\"deleteBucketName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-exclamation-triangle me-2\"></i> <strong>Warning:</strong> This action cannot be undone. All objects in the bucket will be permanently deleted.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" onclick=\"deleteBucket()\"><i class=\"fas fa-trash me-1\"></i>Delete Bucket</button></div></div></div></div><!-- Manage Quota Modal --><div class=\"modal fade\" id=\"manageQuotaModal\" tabindex=\"-1\" aria-labelledby=\"manageQuotaModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"manageQuotaModalLabel\"><i class=\"fas fa-tachometer-alt me-2\"></i>Manage Bucket Quota</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"quotaForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"quotaBucketName\" readonly></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"quotaEnabled\" name=\"quota_enabled\"> <label class=\"form-check-label\" for=\"quotaEnabled\">Enable Storage Quota</label></div></div><div class=\"mb-3\" id=\"quotaSizeSettings\"><div class=\"row\"><div class=\"col-md-8\"><label for=\"quotaSizeMB\" class=\"form-label\">Quota Size</label> <input type=\"number\" class=\"form-control\" id=\"quotaSizeMB\" name=\"quota_size\" placeholder=\"1024\" min=\"0\" step=\"1\"></div><div class=\"col-md-4\"><label for=\"quotaUnitMB\" class=\"form-label\">Unit</label> <select class=\"form-select\" id=\"quotaUnitMB\" name=\"quota_unit\"><option value=\"MB\" selected>MB</option> <option value=\"GB\">GB</option> <option value=\"TB\">TB</option></select></div></div><div class=\"form-text\">Set the maximum storage size for this bucket. Set to 0 to remove quota.</div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-warning\"><i class=\"fas fa-save me-1\"></i>Update Quota</button></div></form></div></div></div><!-- Bucket Details Modal --><div class=\"modal fade\" id=\"bucketDetailsModal\" tabindex=\"-1\" aria-labelledby=\"bucketDetailsModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"bucketDetailsModalLabel\"><i class=\"fas fa-cube me-2\"></i>Bucket Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><div id=\"bucketDetailsContent\"><div class=\"text-center py-4\"><div class=\"spinner-border text-primary\" role=\"status\"><span class=\"visually-hidden\">Loading...</span></div><div class=\"mt-2\">Loading bucket details...</div></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- JavaScript for bucket management --><script>\n document.addEventListener('DOMContentLoaded', function() {\n const quotaCheckbox = document.getElementById('enableQuota');\n const quotaSettings = document.getElementById('quotaSettings');\n const versioningCheckbox = document.getElementById('enableVersioning');\n const objectLockCheckbox = document.getElementById('enableObjectLock');\n const objectLockSettings = document.getElementById('objectLockSettings');\n const createBucketForm = document.getElementById('createBucketForm');\n\n // Toggle quota settings\n quotaCheckbox.addEventListener('change', function() {\n quotaSettings.style.display = this.checked ? 'block' : 'none';\n });\n\n // Toggle object lock settings and automatically enable versioning\n objectLockCheckbox.addEventListener('change', function() {\n objectLockSettings.style.display = this.checked ? 'block' : 'none';\n if (this.checked) {\n versioningCheckbox.checked = true;\n versioningCheckbox.disabled = true;\n } else {\n versioningCheckbox.disabled = false;\n }\n });\n\n // Handle form submission\n createBucketForm.addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const data = {\n name: formData.get('name'),\n region: formData.get('region') || '',\n quota_size: quotaCheckbox.checked ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: quotaCheckbox.checked,\n versioning_enabled: versioningCheckbox.checked,\n object_lock_enabled: objectLockCheckbox.checked,\n object_lock_mode: formData.get('object_lock_mode') || 'GOVERNANCE',\n object_lock_duration: objectLockCheckbox.checked ? parseInt(formData.get('object_lock_duration')) || 30 : 0\n };\n\n // Validate object lock settings\n if (data.object_lock_enabled && data.object_lock_duration <= 0) {\n alert('Please enter a valid retention duration for object lock.');\n return;\n }\n\n fetch('/api/s3/buckets', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error creating bucket: ' + data.error);\n } else {\n alert('Bucket created successfully!');\n // Properly close the modal before reloading\n const createModal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal'));\n if (createModal) {\n createModal.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error creating bucket: ' + error.message);\n });\n });\n\n // Handle delete bucket\n let deleteModalInstance = null;\n document.querySelectorAll('.delete-bucket-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n document.getElementById('deleteBucketName').textContent = bucketName;\n window.currentBucketToDelete = bucketName;\n \n // Dispose of existing modal instance if it exists\n if (deleteModalInstance) {\n deleteModalInstance.dispose();\n }\n \n // Create new modal instance\n deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));\n deleteModalInstance.show();\n });\n });\n\n // Add event listener to properly dispose of delete modal when hidden\n document.getElementById('deleteBucketModal').addEventListener('hidden.bs.modal', function() {\n if (deleteModalInstance) {\n deleteModalInstance.dispose();\n deleteModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n\n // Handle quota management\n let quotaModalInstance = null;\n document.querySelectorAll('.quota-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n const currentQuota = parseInt(this.dataset.currentQuota);\n const quotaEnabled = this.dataset.quotaEnabled === 'true';\n \n document.getElementById('quotaBucketName').value = bucketName;\n document.getElementById('quotaEnabled').checked = quotaEnabled;\n document.getElementById('quotaSizeMB').value = currentQuota;\n \n // Toggle quota size settings\n document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';\n \n window.currentBucketToUpdate = bucketName;\n \n // Dispose of existing modal instance if it exists\n if (quotaModalInstance) {\n quotaModalInstance.dispose();\n }\n \n // Create new modal instance\n quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));\n quotaModalInstance.show();\n });\n });\n\n // Add event listener to properly dispose of quota modal when hidden\n document.getElementById('manageQuotaModal').addEventListener('hidden.bs.modal', function() {\n if (quotaModalInstance) {\n quotaModalInstance.dispose();\n quotaModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n\n // Handle quota form submission\n document.getElementById('quotaForm').addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const enabled = document.getElementById('quotaEnabled').checked;\n const data = {\n quota_size: enabled ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: enabled\n };\n\n fetch(`/api/s3/buckets/${window.currentBucketToUpdate}/quota`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error updating quota: ' + data.error);\n } else {\n alert('Quota updated successfully!');\n // Properly close the modal before reloading\n if (quotaModalInstance) {\n quotaModalInstance.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error updating quota: ' + error.message);\n });\n });\n\n // Handle quota enabled checkbox\n document.getElementById('quotaEnabled').addEventListener('change', function() {\n document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';\n });\n\n // Handle view details button\n let detailsModalInstance = null;\n document.querySelectorAll('.view-details-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n \n // Update modal title\n document.getElementById('bucketDetailsModalLabel').innerHTML = \n '<i class=\"fas fa-cube me-2\"></i>Bucket Details - ' + bucketName;\n \n // Show loading spinner\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"text-center py-4\">' +\n '<div class=\"spinner-border text-primary\" role=\"status\">' +\n '<span class=\"visually-hidden\">Loading...</span>' +\n '</div>' +\n '<div class=\"mt-2\">Loading bucket details...</div>' +\n '</div>';\n \n // Dispose of existing modal instance if it exists\n if (detailsModalInstance) {\n detailsModalInstance.dispose();\n }\n \n // Create new modal instance\n detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));\n detailsModalInstance.show();\n \n // Fetch bucket details\n fetch('/api/s3/buckets/' + bucketName)\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"alert alert-danger\">' +\n '<i class=\"fas fa-exclamation-triangle me-2\"></i>' +\n 'Error loading bucket details: ' + data.error +\n '</div>';\n } else {\n displayBucketDetails(data);\n }\n })\n .catch(error => {\n console.error('Error fetching bucket details:', error);\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"alert alert-danger\">' +\n '<i class=\"fas fa-exclamation-triangle me-2\"></i>' +\n 'Error loading bucket details: ' + error.message +\n '</div>';\n });\n });\n });\n\n // Add event listener to properly dispose of details modal when hidden\n document.getElementById('bucketDetailsModal').addEventListener('hidden.bs.modal', function() {\n if (detailsModalInstance) {\n detailsModalInstance.dispose();\n detailsModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n });\n\n function deleteBucket() {\n const bucketName = window.currentBucketToDelete;\n if (!bucketName) return;\n\n fetch(`/api/s3/buckets/${bucketName}`, {\n method: 'DELETE'\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error deleting bucket: ' + data.error);\n } else {\n alert('Bucket deleted successfully!');\n // Properly close the modal before reloading\n if (deleteModalInstance) {\n deleteModalInstance.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error deleting bucket: ' + error.message);\n });\n }\n\n function displayBucketDetails(data) {\n const bucket = data.bucket;\n const objects = data.objects || [];\n \n // Helper function to format bytes\n function formatBytes(bytes) {\n if (bytes === 0) return '0 Bytes';\n const k = 1024;\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n }\n \n // Helper function to format date\n function formatDate(dateString) {\n const date = new Date(dateString);\n return date.toLocaleString();\n }\n \n // Generate objects table\n let objectsTable = '';\n if (objects.length > 0) {\n objectsTable = '<div class=\"table-responsive\">' +\n '<table class=\"table table-sm table-striped\">' +\n '<thead>' +\n '<tr>' +\n '<th>Object Key</th>' +\n '<th>Size</th>' +\n '<th>Last Modified</th>' +\n '<th>Storage Class</th>' +\n '</tr>' +\n '</thead>' +\n '<tbody>' +\n objects.map(obj => \n '<tr>' +\n '<td><i class=\"fas fa-file me-1\"></i>' + obj.key + '</td>' +\n '<td>' + formatBytes(obj.size) + '</td>' +\n '<td>' + formatDate(obj.last_modified) + '</td>' +\n '<td><span class=\"badge bg-primary\">' + obj.storage_class + '</span></td>' +\n '</tr>'\n ).join('') +\n '</tbody>' +\n '</table>' +\n '</div>';\n } else {\n objectsTable = '<div class=\"text-center py-4 text-muted\">' +\n '<i class=\"fas fa-file fa-3x mb-3\"></i>' +\n '<div>No objects found in this bucket</div>' +\n '</div>';\n }\n \n const content = '<div class=\"row\">' +\n '<div class=\"col-md-6\">' +\n '<h6><i class=\"fas fa-info-circle me-2\"></i>Bucket Information</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr>' +\n '<td><strong>Name:</strong></td>' +\n '<td>' + bucket.name + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Created:</strong></td>' +\n '<td>' + formatDate(bucket.created_at) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Last Modified:</strong></td>' +\n '<td>' + formatDate(bucket.last_modified) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Total Size:</strong></td>' +\n '<td>' + formatBytes(bucket.size) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Object Count:</strong></td>' +\n '<td>' + bucket.object_count + '</td>' +\n '</tr>' +\n '</table>' +\n '</div>' +\n '<div class=\"col-md-6\">' +\n '<h6><i class=\"fas fa-cogs me-2\"></i>Configuration</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr>' +\n '<td><strong>Quota:</strong></td>' +\n '<td>' +\n (bucket.quota_enabled ? \n '<span class=\"badge bg-success\">' + formatBytes(bucket.quota) + '</span>' : \n '<span class=\"badge bg-secondary\">Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Versioning:</strong></td>' +\n '<td>' +\n (bucket.versioning_enabled ? \n '<span class=\"badge bg-success\"><i class=\"fas fa-check me-1\"></i>Enabled</span>' : \n '<span class=\"badge bg-secondary\"><i class=\"fas fa-times me-1\"></i>Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Object Lock:</strong></td>' +\n '<td>' +\n (bucket.object_lock_enabled ? \n '<span class=\"badge bg-warning\"><i class=\"fas fa-lock me-1\"></i>Enabled</span>' +\n '<br><small class=\"text-muted\">' + bucket.object_lock_mode + ' • ' + bucket.object_lock_duration + ' days</small>' : \n '<span class=\"badge bg-secondary\"><i class=\"fas fa-unlock me-1\"></i>Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '</table>' +\n '</div>' +\n '</div>' +\n '<hr>' +\n '<div class=\"row\">' +\n '<div class=\"col-12\">' +\n '<h6><i class=\"fas fa-list me-2\"></i>Objects (' + objects.length + ')</h6>' +\n objectsTable +\n '</div>' +\n '</div>';\n \n document.getElementById('bucketDetailsContent').innerHTML = content;\n }\n\n function exportBucketList() {\n // Simple CSV export\n const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n name: cells[0].textContent.trim(),\n created: cells[1].textContent.trim(),\n objects: cells[2].textContent.trim(),\n size: cells[3].textContent.trim(),\n quota: cells[4].textContent.trim(),\n versioning: cells[5].textContent.trim(),\n objectLock: cells[6].textContent.trim()\n };\n }\n return null;\n }).filter(bucket => bucket !== null);\n\n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Name,Created,Objects,Size,Quota,Versioning,Object Lock\\n\" +\n buckets.map(b => '\"' + b.name + '\",\"' + b.created + '\",\"' + b.objects + '\",\"' + b.size + '\",\"' + b.quota + '\",\"' + b.versioning + '\",\"' + b.objectLock + '\"').join(\"\\n\");\n\n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"buckets.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/layout/layout.templ b/weed/admin/view/layout/layout.templ
index 2261f1e41..b5e2cefbf 100644
--- a/weed/admin/view/layout/layout.templ
+++ b/weed/admin/view/layout/layout.templ
@@ -147,6 +147,11 @@ templ Layout(c *gin.Context, content templ.Component) {
<i class="fas fa-users me-2"></i>Users
</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link py-2" href="/object-store/policies">
+ <i class="fas fa-shield-alt me-2"></i>Policies
+ </a>
+ </li>
</ul>
</div>
</li>
diff --git a/weed/admin/view/layout/layout_templ.go b/weed/admin/view/layout/layout_templ.go
index c321c7a6b..562faa677 100644
--- a/weed/admin/view/layout/layout_templ.go
+++ b/weed/admin/view/layout/layout_templ.go
@@ -62,7 +62,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</a><ul class=\"dropdown-menu\"><li><a class=\"dropdown-item\" href=\"/logout\"><i class=\"fas fa-sign-out-alt me-2\"></i>Logout</a></li></ul></li></ul></div></div></header><div class=\"row g-0\"><!-- Sidebar --><div class=\"col-md-3 col-lg-2 d-md-block bg-light sidebar collapse\"><div class=\"position-sticky pt-3\"><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MAIN</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/admin\"><i class=\"fas fa-tachometer-alt me-2\"></i>Dashboard</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#clusterSubmenu\" aria-expanded=\"false\" aria-controls=\"clusterSubmenu\"><i class=\"fas fa-sitemap me-2\"></i>Cluster <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"clusterSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/masters\"><i class=\"fas fa-crown me-2\"></i>Masters</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volume-servers\"><i class=\"fas fa-server me-2\"></i>Volume Servers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/filers\"><i class=\"fas fa-folder-open me-2\"></i>Filers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volumes\"><i class=\"fas fa-database me-2\"></i>Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/collections\"><i class=\"fas fa-layer-group me-2\"></i>Collections</a></li></ul></div></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MANAGEMENT</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/files\"><i class=\"fas fa-folder me-2\"></i>File Browser</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#objectStoreSubmenu\" aria-expanded=\"false\" aria-controls=\"objectStoreSubmenu\"><i class=\"fas fa-cloud me-2\"></i>Object Store <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"objectStoreSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/buckets\"><i class=\"fas fa-cube me-2\"></i>Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/users\"><i class=\"fas fa-users me-2\"></i>Users</a></li></ul></div></li><li class=\"nav-item\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</a><ul class=\"dropdown-menu\"><li><a class=\"dropdown-item\" href=\"/logout\"><i class=\"fas fa-sign-out-alt me-2\"></i>Logout</a></li></ul></li></ul></div></div></header><div class=\"row g-0\"><!-- Sidebar --><div class=\"col-md-3 col-lg-2 d-md-block bg-light sidebar collapse\"><div class=\"position-sticky pt-3\"><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MAIN</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/admin\"><i class=\"fas fa-tachometer-alt me-2\"></i>Dashboard</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#clusterSubmenu\" aria-expanded=\"false\" aria-controls=\"clusterSubmenu\"><i class=\"fas fa-sitemap me-2\"></i>Cluster <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"clusterSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/masters\"><i class=\"fas fa-crown me-2\"></i>Masters</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volume-servers\"><i class=\"fas fa-server me-2\"></i>Volume Servers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/filers\"><i class=\"fas fa-folder-open me-2\"></i>Filers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volumes\"><i class=\"fas fa-database me-2\"></i>Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/collections\"><i class=\"fas fa-layer-group me-2\"></i>Collections</a></li></ul></div></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MANAGEMENT</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/files\"><i class=\"fas fa-folder me-2\"></i>File Browser</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#objectStoreSubmenu\" aria-expanded=\"false\" aria-controls=\"objectStoreSubmenu\"><i class=\"fas fa-cloud me-2\"></i>Object Store <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"objectStoreSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/buckets\"><i class=\"fas fa-cube me-2\"></i>Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/users\"><i class=\"fas fa-users me-2\"></i>Users</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/policies\"><i class=\"fas fa-shield-alt me-2\"></i>Policies</a></li></ul></div></li><li class=\"nav-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -153,7 +153,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 248, Col: 117}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 253, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -188,7 +188,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 249, Col: 109}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 254, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -206,7 +206,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var7 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 252, Col: 110}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 257, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -241,7 +241,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 253, Col: 109}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 258, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -274,7 +274,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var11 templ.SafeURL
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 265, Col: 106}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 270, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -309,7 +309,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 266, Col: 105}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 271, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -370,7 +370,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year()))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 313, Col: 60}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 318, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -383,7 +383,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 313, Col: 102}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 318, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -435,7 +435,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 337, Col: 17}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 342, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -448,7 +448,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 351, Col: 57}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 356, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -466,7 +466,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 358, Col: 45}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 363, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
diff --git a/weed/credential/credential_store.go b/weed/credential/credential_store.go
index cd36263dc..6fe5a5da1 100644
--- a/weed/credential/credential_store.go
+++ b/weed/credential/credential_store.go
@@ -86,5 +86,27 @@ type UserCredentials struct {
UpdatedAt time.Time `json:"updatedAt"`
}
+// PolicyStatement represents a single policy statement in an IAM policy
+type PolicyStatement struct {
+ Effect string `json:"Effect"`
+ Action []string `json:"Action"`
+ Resource []string `json:"Resource"`
+}
+
+// PolicyDocument represents an IAM policy document
+type PolicyDocument struct {
+ Version string `json:"Version"`
+ Statement []*PolicyStatement `json:"Statement"`
+}
+
+// PolicyManager interface for managing IAM policies
+type PolicyManager interface {
+ GetPolicies(ctx context.Context) (map[string]PolicyDocument, error)
+ CreatePolicy(ctx context.Context, name string, document PolicyDocument) error
+ UpdatePolicy(ctx context.Context, name string, document PolicyDocument) error
+ DeletePolicy(ctx context.Context, name string) error
+ GetPolicy(ctx context.Context, name string) (*PolicyDocument, error)
+}
+
// Stores holds all available credential store implementations
var Stores []CredentialStore
diff --git a/weed/credential/filer_etc/filer_etc_identity.go b/weed/credential/filer_etc/filer_etc_identity.go
new file mode 100644
index 000000000..103c988ff
--- /dev/null
+++ b/weed/credential/filer_etc/filer_etc_identity.go
@@ -0,0 +1,188 @@
+package filer_etc
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/filer"
+ "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
+ "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
+)
+
+func (store *FilerEtcStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
+ s3cfg := &iam_pb.S3ApiConfiguration{}
+
+ err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
+ var buf bytes.Buffer
+ if err := filer.ReadEntry(nil, client, filer.IamConfigDirectory, filer.IamIdentityFile, &buf); err != nil {
+ if err != filer_pb.ErrNotFound {
+ return err
+ }
+ }
+ if buf.Len() > 0 {
+ return filer.ParseS3ConfigurationFromBytes(buf.Bytes(), s3cfg)
+ }
+ return nil
+ })
+
+ return s3cfg, err
+}
+
+func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
+ return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
+ var buf bytes.Buffer
+ if err := filer.ProtoToText(&buf, config); err != nil {
+ return fmt.Errorf("failed to marshal configuration: %v", err)
+ }
+ return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamIdentityFile, buf.Bytes())
+ })
+}
+
+func (store *FilerEtcStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
+ // Load existing configuration
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to load configuration: %v", err)
+ }
+
+ // Check if user already exists
+ for _, existingIdentity := range config.Identities {
+ if existingIdentity.Name == identity.Name {
+ return credential.ErrUserAlreadyExists
+ }
+ }
+
+ // Add new identity
+ config.Identities = append(config.Identities, identity)
+
+ // Save configuration
+ return store.SaveConfiguration(ctx, config)
+}
+
+func (store *FilerEtcStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load configuration: %v", err)
+ }
+
+ for _, identity := range config.Identities {
+ if identity.Name == username {
+ return identity, nil
+ }
+ }
+
+ return nil, credential.ErrUserNotFound
+}
+
+func (store *FilerEtcStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to load configuration: %v", err)
+ }
+
+ // Find and update the user
+ for i, existingIdentity := range config.Identities {
+ if existingIdentity.Name == username {
+ config.Identities[i] = identity
+ return store.SaveConfiguration(ctx, config)
+ }
+ }
+
+ return credential.ErrUserNotFound
+}
+
+func (store *FilerEtcStore) DeleteUser(ctx context.Context, username string) error {
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to load configuration: %v", err)
+ }
+
+ // Find and remove the user
+ for i, identity := range config.Identities {
+ if identity.Name == username {
+ config.Identities = append(config.Identities[:i], config.Identities[i+1:]...)
+ return store.SaveConfiguration(ctx, config)
+ }
+ }
+
+ return credential.ErrUserNotFound
+}
+
+func (store *FilerEtcStore) ListUsers(ctx context.Context) ([]string, error) {
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load configuration: %v", err)
+ }
+
+ var usernames []string
+ for _, identity := range config.Identities {
+ usernames = append(usernames, identity.Name)
+ }
+
+ return usernames, nil
+}
+
+func (store *FilerEtcStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load configuration: %v", err)
+ }
+
+ for _, identity := range config.Identities {
+ for _, credential := range identity.Credentials {
+ if credential.AccessKey == accessKey {
+ return identity, nil
+ }
+ }
+ }
+
+ return nil, credential.ErrAccessKeyNotFound
+}
+
+func (store *FilerEtcStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to load configuration: %v", err)
+ }
+
+ // Find the user and add the credential
+ for _, identity := range config.Identities {
+ if identity.Name == username {
+ // Check if access key already exists
+ for _, existingCred := range identity.Credentials {
+ if existingCred.AccessKey == cred.AccessKey {
+ return fmt.Errorf("access key %s already exists", cred.AccessKey)
+ }
+ }
+
+ identity.Credentials = append(identity.Credentials, cred)
+ return store.SaveConfiguration(ctx, config)
+ }
+ }
+
+ return credential.ErrUserNotFound
+}
+
+func (store *FilerEtcStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to load configuration: %v", err)
+ }
+
+ // Find the user and remove the credential
+ for _, identity := range config.Identities {
+ if identity.Name == username {
+ for i, cred := range identity.Credentials {
+ if cred.AccessKey == accessKey {
+ identity.Credentials = append(identity.Credentials[:i], identity.Credentials[i+1:]...)
+ return store.SaveConfiguration(ctx, config)
+ }
+ }
+ return credential.ErrAccessKeyNotFound
+ }
+ }
+
+ return credential.ErrUserNotFound
+}
diff --git a/weed/credential/filer_etc/filer_etc_policy.go b/weed/credential/filer_etc/filer_etc_policy.go
new file mode 100644
index 000000000..fdd3156ff
--- /dev/null
+++ b/weed/credential/filer_etc/filer_etc_policy.go
@@ -0,0 +1,114 @@
+package filer_etc
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/filer"
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
+)
+
+type PoliciesCollection struct {
+ Policies map[string]credential.PolicyDocument `json:"policies"`
+}
+
+// GetPolicies retrieves all IAM policies from the filer
+func (store *FilerEtcStore) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) {
+ policiesCollection := &PoliciesCollection{
+ Policies: make(map[string]credential.PolicyDocument),
+ }
+
+ // Check if filer client is configured
+ if store.filerGrpcAddress == "" {
+ glog.V(1).Infof("Filer client not configured for policy retrieval, returning empty policies")
+ // Return empty policies if filer client is not configured
+ return policiesCollection.Policies, nil
+ }
+
+ err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
+ var buf bytes.Buffer
+ if err := filer.ReadEntry(nil, client, filer.IamConfigDirectory, filer.IamPoliciesFile, &buf); err != nil {
+ if err == filer_pb.ErrNotFound {
+ glog.V(1).Infof("Policies file not found at %s/%s, returning empty policies", filer.IamConfigDirectory, filer.IamPoliciesFile)
+ // If file doesn't exist, return empty collection
+ return nil
+ }
+ return err
+ }
+
+ if buf.Len() > 0 {
+ return json.Unmarshal(buf.Bytes(), policiesCollection)
+ }
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ return policiesCollection.Policies, nil
+}
+
+// CreatePolicy creates a new IAM policy in the filer
+func (store *FilerEtcStore) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
+ return store.updatePolicies(ctx, func(policies map[string]credential.PolicyDocument) {
+ policies[name] = document
+ })
+}
+
+// UpdatePolicy updates an existing IAM policy in the filer
+func (store *FilerEtcStore) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
+ return store.updatePolicies(ctx, func(policies map[string]credential.PolicyDocument) {
+ policies[name] = document
+ })
+}
+
+// DeletePolicy deletes an IAM policy from the filer
+func (store *FilerEtcStore) DeletePolicy(ctx context.Context, name string) error {
+ return store.updatePolicies(ctx, func(policies map[string]credential.PolicyDocument) {
+ delete(policies, name)
+ })
+}
+
+// updatePolicies is a helper method to update policies atomically
+func (store *FilerEtcStore) updatePolicies(ctx context.Context, updateFunc func(map[string]credential.PolicyDocument)) error {
+ // Load existing policies
+ policies, err := store.GetPolicies(ctx)
+ if err != nil {
+ return err
+ }
+
+ // Apply update
+ updateFunc(policies)
+
+ // Save back to filer
+ policiesCollection := &PoliciesCollection{
+ Policies: policies,
+ }
+
+ data, err := json.Marshal(policiesCollection)
+ if err != nil {
+ return err
+ }
+
+ return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
+ return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamPoliciesFile, data)
+ })
+}
+
+// GetPolicy retrieves a specific IAM policy by name from the filer
+func (store *FilerEtcStore) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) {
+ policies, err := store.GetPolicies(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ if policy, exists := policies[name]; exists {
+ return &policy, nil
+ }
+
+ return nil, nil // Policy not found
+}
diff --git a/weed/credential/filer_etc/filer_etc_store.go b/weed/credential/filer_etc/filer_etc_store.go
index 6951cc103..f8750cb25 100644
--- a/weed/credential/filer_etc/filer_etc_store.go
+++ b/weed/credential/filer_etc/filer_etc_store.go
@@ -1,15 +1,11 @@
package filer_etc
import (
- "bytes"
- "context"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
- "github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
- "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
)
@@ -54,182 +50,6 @@ func (store *FilerEtcStore) withFilerClient(fn func(client filer_pb.SeaweedFiler
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(store.filerGrpcAddress), store.grpcDialOption, fn)
}
-func (store *FilerEtcStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
- s3cfg := &iam_pb.S3ApiConfiguration{}
-
- err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
- var buf bytes.Buffer
- if err := filer.ReadEntry(nil, client, filer.IamConfigDirectory, filer.IamIdentityFile, &buf); err != nil {
- if err != filer_pb.ErrNotFound {
- return err
- }
- }
- if buf.Len() > 0 {
- return filer.ParseS3ConfigurationFromBytes(buf.Bytes(), s3cfg)
- }
- return nil
- })
-
- return s3cfg, err
-}
-
-func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
- return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
- var buf bytes.Buffer
- if err := filer.ProtoToText(&buf, config); err != nil {
- return fmt.Errorf("failed to marshal configuration: %v", err)
- }
- return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamIdentityFile, buf.Bytes())
- })
-}
-
-func (store *FilerEtcStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
- // Load existing configuration
- config, err := store.LoadConfiguration(ctx)
- if err != nil {
- return fmt.Errorf("failed to load configuration: %v", err)
- }
-
- // Check if user already exists
- for _, existingIdentity := range config.Identities {
- if existingIdentity.Name == identity.Name {
- return credential.ErrUserAlreadyExists
- }
- }
-
- // Add new identity
- config.Identities = append(config.Identities, identity)
-
- // Save configuration
- return store.SaveConfiguration(ctx, config)
-}
-
-func (store *FilerEtcStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
- config, err := store.LoadConfiguration(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to load configuration: %v", err)
- }
-
- for _, identity := range config.Identities {
- if identity.Name == username {
- return identity, nil
- }
- }
-
- return nil, credential.ErrUserNotFound
-}
-
-func (store *FilerEtcStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
- config, err := store.LoadConfiguration(ctx)
- if err != nil {
- return fmt.Errorf("failed to load configuration: %v", err)
- }
-
- // Find and update the user
- for i, existingIdentity := range config.Identities {
- if existingIdentity.Name == username {
- config.Identities[i] = identity
- return store.SaveConfiguration(ctx, config)
- }
- }
-
- return credential.ErrUserNotFound
-}
-
-func (store *FilerEtcStore) DeleteUser(ctx context.Context, username string) error {
- config, err := store.LoadConfiguration(ctx)
- if err != nil {
- return fmt.Errorf("failed to load configuration: %v", err)
- }
-
- // Find and remove the user
- for i, identity := range config.Identities {
- if identity.Name == username {
- config.Identities = append(config.Identities[:i], config.Identities[i+1:]...)
- return store.SaveConfiguration(ctx, config)
- }
- }
-
- return credential.ErrUserNotFound
-}
-
-func (store *FilerEtcStore) ListUsers(ctx context.Context) ([]string, error) {
- config, err := store.LoadConfiguration(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to load configuration: %v", err)
- }
-
- var usernames []string
- for _, identity := range config.Identities {
- usernames = append(usernames, identity.Name)
- }
-
- return usernames, nil
-}
-
-func (store *FilerEtcStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
- config, err := store.LoadConfiguration(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to load configuration: %v", err)
- }
-
- for _, identity := range config.Identities {
- for _, credential := range identity.Credentials {
- if credential.AccessKey == accessKey {
- return identity, nil
- }
- }
- }
-
- return nil, credential.ErrAccessKeyNotFound
-}
-
-func (store *FilerEtcStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
- config, err := store.LoadConfiguration(ctx)
- if err != nil {
- return fmt.Errorf("failed to load configuration: %v", err)
- }
-
- // Find the user and add the credential
- for _, identity := range config.Identities {
- if identity.Name == username {
- // Check if access key already exists
- for _, existingCred := range identity.Credentials {
- if existingCred.AccessKey == cred.AccessKey {
- return fmt.Errorf("access key %s already exists", cred.AccessKey)
- }
- }
-
- identity.Credentials = append(identity.Credentials, cred)
- return store.SaveConfiguration(ctx, config)
- }
- }
-
- return credential.ErrUserNotFound
-}
-
-func (store *FilerEtcStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
- config, err := store.LoadConfiguration(ctx)
- if err != nil {
- return fmt.Errorf("failed to load configuration: %v", err)
- }
-
- // Find the user and remove the credential
- for _, identity := range config.Identities {
- if identity.Name == username {
- for i, cred := range identity.Credentials {
- if cred.AccessKey == accessKey {
- identity.Credentials = append(identity.Credentials[:i], identity.Credentials[i+1:]...)
- return store.SaveConfiguration(ctx, config)
- }
- }
- return credential.ErrAccessKeyNotFound
- }
- }
-
- return credential.ErrUserNotFound
-}
-
func (store *FilerEtcStore) Shutdown() {
// No cleanup needed for file store
}
diff --git a/weed/credential/memory/memory_identity.go b/weed/credential/memory/memory_identity.go
new file mode 100644
index 000000000..191aa5d16
--- /dev/null
+++ b/weed/credential/memory/memory_identity.go
@@ -0,0 +1,302 @@
+package memory
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
+)
+
+func (store *MemoryStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+
+ if !store.initialized {
+ return nil, fmt.Errorf("store not initialized")
+ }
+
+ config := &iam_pb.S3ApiConfiguration{}
+
+ // Convert all users to identities
+ for _, user := range store.users {
+ // Deep copy the identity to avoid mutation issues
+ identityCopy := store.deepCopyIdentity(user)
+ config.Identities = append(config.Identities, identityCopy)
+ }
+
+ return config, nil
+}
+
+func (store *MemoryStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ if !store.initialized {
+ return fmt.Errorf("store not initialized")
+ }
+
+ // Clear existing data
+ store.users = make(map[string]*iam_pb.Identity)
+ store.accessKeys = make(map[string]string)
+
+ // Add all identities
+ for _, identity := range config.Identities {
+ // Deep copy to avoid mutation issues
+ identityCopy := store.deepCopyIdentity(identity)
+ store.users[identity.Name] = identityCopy
+
+ // Index access keys
+ for _, credential := range identity.Credentials {
+ store.accessKeys[credential.AccessKey] = identity.Name
+ }
+ }
+
+ return nil
+}
+
+func (store *MemoryStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ if !store.initialized {
+ return fmt.Errorf("store not initialized")
+ }
+
+ if _, exists := store.users[identity.Name]; exists {
+ return credential.ErrUserAlreadyExists
+ }
+
+ // Check for duplicate access keys
+ for _, cred := range identity.Credentials {
+ if _, exists := store.accessKeys[cred.AccessKey]; exists {
+ return fmt.Errorf("access key %s already exists", cred.AccessKey)
+ }
+ }
+
+ // Deep copy to avoid mutation issues
+ identityCopy := store.deepCopyIdentity(identity)
+ store.users[identity.Name] = identityCopy
+
+ // Index access keys
+ for _, cred := range identity.Credentials {
+ store.accessKeys[cred.AccessKey] = identity.Name
+ }
+
+ return nil
+}
+
+func (store *MemoryStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+
+ if !store.initialized {
+ return nil, fmt.Errorf("store not initialized")
+ }
+
+ user, exists := store.users[username]
+ if !exists {
+ return nil, credential.ErrUserNotFound
+ }
+
+ // Return a deep copy to avoid mutation issues
+ return store.deepCopyIdentity(user), nil
+}
+
+func (store *MemoryStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ if !store.initialized {
+ return fmt.Errorf("store not initialized")
+ }
+
+ existingUser, exists := store.users[username]
+ if !exists {
+ return credential.ErrUserNotFound
+ }
+
+ // Remove old access keys from index
+ for _, cred := range existingUser.Credentials {
+ delete(store.accessKeys, cred.AccessKey)
+ }
+
+ // Check for duplicate access keys (excluding current user)
+ for _, cred := range identity.Credentials {
+ if existingUsername, exists := store.accessKeys[cred.AccessKey]; exists && existingUsername != username {
+ return fmt.Errorf("access key %s already exists", cred.AccessKey)
+ }
+ }
+
+ // Deep copy to avoid mutation issues
+ identityCopy := store.deepCopyIdentity(identity)
+ store.users[username] = identityCopy
+
+ // Re-index access keys
+ for _, cred := range identity.Credentials {
+ store.accessKeys[cred.AccessKey] = username
+ }
+
+ return nil
+}
+
+func (store *MemoryStore) DeleteUser(ctx context.Context, username string) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ if !store.initialized {
+ return fmt.Errorf("store not initialized")
+ }
+
+ user, exists := store.users[username]
+ if !exists {
+ return credential.ErrUserNotFound
+ }
+
+ // Remove access keys from index
+ for _, cred := range user.Credentials {
+ delete(store.accessKeys, cred.AccessKey)
+ }
+
+ // Remove user
+ delete(store.users, username)
+
+ return nil
+}
+
+func (store *MemoryStore) ListUsers(ctx context.Context) ([]string, error) {
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+
+ if !store.initialized {
+ return nil, fmt.Errorf("store not initialized")
+ }
+
+ var usernames []string
+ for username := range store.users {
+ usernames = append(usernames, username)
+ }
+
+ return usernames, nil
+}
+
+func (store *MemoryStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+
+ if !store.initialized {
+ return nil, fmt.Errorf("store not initialized")
+ }
+
+ username, exists := store.accessKeys[accessKey]
+ if !exists {
+ return nil, credential.ErrAccessKeyNotFound
+ }
+
+ user, exists := store.users[username]
+ if !exists {
+ // This should not happen, but handle it gracefully
+ return nil, credential.ErrUserNotFound
+ }
+
+ // Return a deep copy to avoid mutation issues
+ return store.deepCopyIdentity(user), nil
+}
+
+func (store *MemoryStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ if !store.initialized {
+ return fmt.Errorf("store not initialized")
+ }
+
+ user, exists := store.users[username]
+ if !exists {
+ return credential.ErrUserNotFound
+ }
+
+ // Check if access key already exists
+ if _, exists := store.accessKeys[cred.AccessKey]; exists {
+ return fmt.Errorf("access key %s already exists", cred.AccessKey)
+ }
+
+ // Add credential to user
+ user.Credentials = append(user.Credentials, &iam_pb.Credential{
+ AccessKey: cred.AccessKey,
+ SecretKey: cred.SecretKey,
+ })
+
+ // Index the access key
+ store.accessKeys[cred.AccessKey] = username
+
+ return nil
+}
+
+func (store *MemoryStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ if !store.initialized {
+ return fmt.Errorf("store not initialized")
+ }
+
+ user, exists := store.users[username]
+ if !exists {
+ return credential.ErrUserNotFound
+ }
+
+ // Find and remove the credential
+ var newCredentials []*iam_pb.Credential
+ found := false
+ for _, cred := range user.Credentials {
+ if cred.AccessKey == accessKey {
+ found = true
+ // Remove from access key index
+ delete(store.accessKeys, accessKey)
+ } else {
+ newCredentials = append(newCredentials, cred)
+ }
+ }
+
+ if !found {
+ return credential.ErrAccessKeyNotFound
+ }
+
+ user.Credentials = newCredentials
+ return nil
+}
+
+// deepCopyIdentity creates a deep copy of an identity to avoid mutation issues
+func (store *MemoryStore) deepCopyIdentity(identity *iam_pb.Identity) *iam_pb.Identity {
+ if identity == nil {
+ return nil
+ }
+
+ // Use JSON marshaling/unmarshaling for deep copy
+ // This is simple and safe for protobuf messages
+ data, err := json.Marshal(identity)
+ if err != nil {
+ // Fallback to shallow copy if JSON fails
+ return &iam_pb.Identity{
+ Name: identity.Name,
+ Account: identity.Account,
+ Credentials: identity.Credentials,
+ Actions: identity.Actions,
+ }
+ }
+
+ var copy iam_pb.Identity
+ if err := json.Unmarshal(data, &copy); err != nil {
+ // Fallback to shallow copy if JSON fails
+ return &iam_pb.Identity{
+ Name: identity.Name,
+ Account: identity.Account,
+ Credentials: identity.Credentials,
+ Actions: identity.Actions,
+ }
+ }
+
+ return &copy
+}
diff --git a/weed/credential/memory/memory_policy.go b/weed/credential/memory/memory_policy.go
new file mode 100644
index 000000000..1c9268958
--- /dev/null
+++ b/weed/credential/memory/memory_policy.go
@@ -0,0 +1,77 @@
+package memory
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+)
+
+// GetPolicies retrieves all IAM policies from memory
+func (store *MemoryStore) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) {
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+
+ if !store.initialized {
+ return nil, fmt.Errorf("store not initialized")
+ }
+
+ // Create a copy of the policies map to avoid mutation issues
+ policies := make(map[string]credential.PolicyDocument)
+ for name, doc := range store.policies {
+ policies[name] = doc
+ }
+
+ return policies, nil
+}
+
+// GetPolicy retrieves a specific IAM policy by name from memory
+func (store *MemoryStore) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) {
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+
+ if policy, exists := store.policies[name]; exists {
+ return &policy, nil
+ }
+
+ return nil, nil // Policy not found
+}
+
+// CreatePolicy creates a new IAM policy in memory
+func (store *MemoryStore) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ if !store.initialized {
+ return fmt.Errorf("store not initialized")
+ }
+
+ store.policies[name] = document
+ return nil
+}
+
+// UpdatePolicy updates an existing IAM policy in memory
+func (store *MemoryStore) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ if !store.initialized {
+ return fmt.Errorf("store not initialized")
+ }
+
+ store.policies[name] = document
+ return nil
+}
+
+// DeletePolicy deletes an IAM policy from memory
+func (store *MemoryStore) DeletePolicy(ctx context.Context, name string) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ if !store.initialized {
+ return fmt.Errorf("store not initialized")
+ }
+
+ delete(store.policies, name)
+ return nil
+}
diff --git a/weed/credential/memory/memory_store.go b/weed/credential/memory/memory_store.go
index e6117bf48..f0f383c04 100644
--- a/weed/credential/memory/memory_store.go
+++ b/weed/credential/memory/memory_store.go
@@ -1,9 +1,6 @@
package memory
import (
- "context"
- "encoding/json"
- "fmt"
"sync"
"github.com/seaweedfs/seaweedfs/weed/credential"
@@ -19,8 +16,9 @@ func init() {
// This is primarily intended for testing purposes
type MemoryStore struct {
mu sync.RWMutex
- users map[string]*iam_pb.Identity // username -> identity
- accessKeys map[string]string // access_key -> username
+ users map[string]*iam_pb.Identity // username -> identity
+ accessKeys map[string]string // access_key -> username
+ policies map[string]credential.PolicyDocument // policy_name -> policy_document
initialized bool
}
@@ -38,313 +36,22 @@ func (store *MemoryStore) Initialize(configuration util.Configuration, prefix st
store.users = make(map[string]*iam_pb.Identity)
store.accessKeys = make(map[string]string)
+ store.policies = make(map[string]credential.PolicyDocument)
store.initialized = true
return nil
}
-func (store *MemoryStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
- store.mu.RLock()
- defer store.mu.RUnlock()
-
- if !store.initialized {
- return nil, fmt.Errorf("store not initialized")
- }
-
- config := &iam_pb.S3ApiConfiguration{}
-
- // Convert all users to identities
- for _, user := range store.users {
- // Deep copy the identity to avoid mutation issues
- identityCopy := store.deepCopyIdentity(user)
- config.Identities = append(config.Identities, identityCopy)
- }
-
- return config, nil
-}
-
-func (store *MemoryStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
- store.mu.Lock()
- defer store.mu.Unlock()
-
- if !store.initialized {
- return fmt.Errorf("store not initialized")
- }
-
- // Clear existing data
- store.users = make(map[string]*iam_pb.Identity)
- store.accessKeys = make(map[string]string)
-
- // Add all identities
- for _, identity := range config.Identities {
- // Deep copy to avoid mutation issues
- identityCopy := store.deepCopyIdentity(identity)
- store.users[identity.Name] = identityCopy
-
- // Index access keys
- for _, credential := range identity.Credentials {
- store.accessKeys[credential.AccessKey] = identity.Name
- }
- }
-
- return nil
-}
-
-func (store *MemoryStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
- store.mu.Lock()
- defer store.mu.Unlock()
-
- if !store.initialized {
- return fmt.Errorf("store not initialized")
- }
-
- if _, exists := store.users[identity.Name]; exists {
- return credential.ErrUserAlreadyExists
- }
-
- // Check for duplicate access keys
- for _, cred := range identity.Credentials {
- if _, exists := store.accessKeys[cred.AccessKey]; exists {
- return fmt.Errorf("access key %s already exists", cred.AccessKey)
- }
- }
-
- // Deep copy to avoid mutation issues
- identityCopy := store.deepCopyIdentity(identity)
- store.users[identity.Name] = identityCopy
-
- // Index access keys
- for _, cred := range identity.Credentials {
- store.accessKeys[cred.AccessKey] = identity.Name
- }
-
- return nil
-}
-
-func (store *MemoryStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
- store.mu.RLock()
- defer store.mu.RUnlock()
-
- if !store.initialized {
- return nil, fmt.Errorf("store not initialized")
- }
-
- user, exists := store.users[username]
- if !exists {
- return nil, credential.ErrUserNotFound
- }
-
- // Return a deep copy to avoid mutation issues
- return store.deepCopyIdentity(user), nil
-}
-
-func (store *MemoryStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
- store.mu.Lock()
- defer store.mu.Unlock()
-
- if !store.initialized {
- return fmt.Errorf("store not initialized")
- }
-
- existingUser, exists := store.users[username]
- if !exists {
- return credential.ErrUserNotFound
- }
-
- // Remove old access keys from index
- for _, cred := range existingUser.Credentials {
- delete(store.accessKeys, cred.AccessKey)
- }
-
- // Check for duplicate access keys (excluding current user)
- for _, cred := range identity.Credentials {
- if existingUsername, exists := store.accessKeys[cred.AccessKey]; exists && existingUsername != username {
- return fmt.Errorf("access key %s already exists", cred.AccessKey)
- }
- }
-
- // Deep copy to avoid mutation issues
- identityCopy := store.deepCopyIdentity(identity)
- store.users[username] = identityCopy
-
- // Re-index access keys
- for _, cred := range identity.Credentials {
- store.accessKeys[cred.AccessKey] = username
- }
-
- return nil
-}
-
-func (store *MemoryStore) DeleteUser(ctx context.Context, username string) error {
- store.mu.Lock()
- defer store.mu.Unlock()
-
- if !store.initialized {
- return fmt.Errorf("store not initialized")
- }
-
- user, exists := store.users[username]
- if !exists {
- return credential.ErrUserNotFound
- }
-
- // Remove access keys from index
- for _, cred := range user.Credentials {
- delete(store.accessKeys, cred.AccessKey)
- }
-
- // Remove user
- delete(store.users, username)
-
- return nil
-}
-
-func (store *MemoryStore) ListUsers(ctx context.Context) ([]string, error) {
- store.mu.RLock()
- defer store.mu.RUnlock()
-
- if !store.initialized {
- return nil, fmt.Errorf("store not initialized")
- }
-
- var usernames []string
- for username := range store.users {
- usernames = append(usernames, username)
- }
-
- return usernames, nil
-}
-
-func (store *MemoryStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
- store.mu.RLock()
- defer store.mu.RUnlock()
-
- if !store.initialized {
- return nil, fmt.Errorf("store not initialized")
- }
-
- username, exists := store.accessKeys[accessKey]
- if !exists {
- return nil, credential.ErrAccessKeyNotFound
- }
-
- user, exists := store.users[username]
- if !exists {
- // This should not happen, but handle it gracefully
- return nil, credential.ErrUserNotFound
- }
-
- // Return a deep copy to avoid mutation issues
- return store.deepCopyIdentity(user), nil
-}
-
-func (store *MemoryStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
- store.mu.Lock()
- defer store.mu.Unlock()
-
- if !store.initialized {
- return fmt.Errorf("store not initialized")
- }
-
- user, exists := store.users[username]
- if !exists {
- return credential.ErrUserNotFound
- }
-
- // Check if access key already exists
- if _, exists := store.accessKeys[cred.AccessKey]; exists {
- return fmt.Errorf("access key %s already exists", cred.AccessKey)
- }
-
- // Add credential to user
- user.Credentials = append(user.Credentials, &iam_pb.Credential{
- AccessKey: cred.AccessKey,
- SecretKey: cred.SecretKey,
- })
-
- // Index the access key
- store.accessKeys[cred.AccessKey] = username
-
- return nil
-}
-
-func (store *MemoryStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
- store.mu.Lock()
- defer store.mu.Unlock()
-
- if !store.initialized {
- return fmt.Errorf("store not initialized")
- }
-
- user, exists := store.users[username]
- if !exists {
- return credential.ErrUserNotFound
- }
-
- // Find and remove the credential
- var newCredentials []*iam_pb.Credential
- found := false
- for _, cred := range user.Credentials {
- if cred.AccessKey == accessKey {
- found = true
- // Remove from access key index
- delete(store.accessKeys, accessKey)
- } else {
- newCredentials = append(newCredentials, cred)
- }
- }
-
- if !found {
- return credential.ErrAccessKeyNotFound
- }
-
- user.Credentials = newCredentials
- return nil
-}
-
func (store *MemoryStore) Shutdown() {
store.mu.Lock()
defer store.mu.Unlock()
- // Clear all data
store.users = nil
store.accessKeys = nil
+ store.policies = nil
store.initialized = false
}
-// deepCopyIdentity creates a deep copy of an identity to avoid mutation issues
-func (store *MemoryStore) deepCopyIdentity(identity *iam_pb.Identity) *iam_pb.Identity {
- if identity == nil {
- return nil
- }
-
- // Use JSON marshaling/unmarshaling for deep copy
- // This is simple and safe for protobuf messages
- data, err := json.Marshal(identity)
- if err != nil {
- // Fallback to shallow copy if JSON fails
- return &iam_pb.Identity{
- Name: identity.Name,
- Account: identity.Account,
- Credentials: identity.Credentials,
- Actions: identity.Actions,
- }
- }
-
- var copy iam_pb.Identity
- if err := json.Unmarshal(data, &copy); err != nil {
- // Fallback to shallow copy if JSON fails
- return &iam_pb.Identity{
- Name: identity.Name,
- Account: identity.Account,
- Credentials: identity.Credentials,
- Actions: identity.Actions,
- }
- }
-
- return &copy
-}
-
// Reset clears all data in the store (useful for testing)
func (store *MemoryStore) Reset() {
store.mu.Lock()
diff --git a/weed/credential/postgres/postgres_identity.go b/weed/credential/postgres/postgres_identity.go
new file mode 100644
index 000000000..ea3627c50
--- /dev/null
+++ b/weed/credential/postgres/postgres_identity.go
@@ -0,0 +1,446 @@
+package postgres
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
+)
+
+func (store *PostgresStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
+ if !store.configured {
+ return nil, fmt.Errorf("store not configured")
+ }
+
+ config := &iam_pb.S3ApiConfiguration{}
+
+ // Query all users
+ rows, err := store.db.QueryContext(ctx, "SELECT username, email, account_data, actions FROM users")
+ if err != nil {
+ return nil, fmt.Errorf("failed to query users: %v", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var username, email string
+ var accountDataJSON, actionsJSON []byte
+
+ if err := rows.Scan(&username, &email, &accountDataJSON, &actionsJSON); err != nil {
+ return nil, fmt.Errorf("failed to scan user row: %v", err)
+ }
+
+ identity := &iam_pb.Identity{
+ Name: username,
+ }
+
+ // Parse account data
+ if len(accountDataJSON) > 0 {
+ if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal account data for user %s: %v", username, err)
+ }
+ }
+
+ // Parse actions
+ if len(actionsJSON) > 0 {
+ if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal actions for user %s: %v", username, err)
+ }
+ }
+
+ // Query credentials for this user
+ credRows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query credentials for user %s: %v", username, err)
+ }
+
+ for credRows.Next() {
+ var accessKey, secretKey string
+ if err := credRows.Scan(&accessKey, &secretKey); err != nil {
+ credRows.Close()
+ return nil, fmt.Errorf("failed to scan credential row for user %s: %v", username, err)
+ }
+
+ identity.Credentials = append(identity.Credentials, &iam_pb.Credential{
+ AccessKey: accessKey,
+ SecretKey: secretKey,
+ })
+ }
+ credRows.Close()
+
+ config.Identities = append(config.Identities, identity)
+ }
+
+ return config, nil
+}
+
+func (store *PostgresStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
+ if !store.configured {
+ return fmt.Errorf("store not configured")
+ }
+
+ // Start transaction
+ tx, err := store.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("failed to begin transaction: %v", err)
+ }
+ defer tx.Rollback()
+
+ // Clear existing data
+ if _, err := tx.ExecContext(ctx, "DELETE FROM credentials"); err != nil {
+ return fmt.Errorf("failed to clear credentials: %v", err)
+ }
+ if _, err := tx.ExecContext(ctx, "DELETE FROM users"); err != nil {
+ return fmt.Errorf("failed to clear users: %v", err)
+ }
+
+ // Insert all identities
+ for _, identity := range config.Identities {
+ // Marshal account data
+ var accountDataJSON []byte
+ if identity.Account != nil {
+ accountDataJSON, err = json.Marshal(identity.Account)
+ if err != nil {
+ return fmt.Errorf("failed to marshal account data for user %s: %v", identity.Name, err)
+ }
+ }
+
+ // Marshal actions
+ var actionsJSON []byte
+ if identity.Actions != nil {
+ actionsJSON, err = json.Marshal(identity.Actions)
+ if err != nil {
+ return fmt.Errorf("failed to marshal actions for user %s: %v", identity.Name, err)
+ }
+ }
+
+ // Insert user
+ _, err := tx.ExecContext(ctx,
+ "INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)",
+ identity.Name, "", accountDataJSON, actionsJSON)
+ if err != nil {
+ return fmt.Errorf("failed to insert user %s: %v", identity.Name, err)
+ }
+
+ // Insert credentials
+ for _, cred := range identity.Credentials {
+ _, err := tx.ExecContext(ctx,
+ "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
+ identity.Name, cred.AccessKey, cred.SecretKey)
+ if err != nil {
+ return fmt.Errorf("failed to insert credential for user %s: %v", identity.Name, err)
+ }
+ }
+ }
+
+ return tx.Commit()
+}
+
+func (store *PostgresStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
+ if !store.configured {
+ return fmt.Errorf("store not configured")
+ }
+
+ // Check if user already exists
+ var count int
+ err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", identity.Name).Scan(&count)
+ if err != nil {
+ return fmt.Errorf("failed to check user existence: %v", err)
+ }
+ if count > 0 {
+ return credential.ErrUserAlreadyExists
+ }
+
+ // Start transaction
+ tx, err := store.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("failed to begin transaction: %v", err)
+ }
+ defer tx.Rollback()
+
+ // Marshal account data
+ var accountDataJSON []byte
+ if identity.Account != nil {
+ accountDataJSON, err = json.Marshal(identity.Account)
+ if err != nil {
+ return fmt.Errorf("failed to marshal account data: %v", err)
+ }
+ }
+
+ // Marshal actions
+ var actionsJSON []byte
+ if identity.Actions != nil {
+ actionsJSON, err = json.Marshal(identity.Actions)
+ if err != nil {
+ return fmt.Errorf("failed to marshal actions: %v", err)
+ }
+ }
+
+ // Insert user
+ _, err = tx.ExecContext(ctx,
+ "INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)",
+ identity.Name, "", accountDataJSON, actionsJSON)
+ if err != nil {
+ return fmt.Errorf("failed to insert user: %v", err)
+ }
+
+ // Insert credentials
+ for _, cred := range identity.Credentials {
+ _, err = tx.ExecContext(ctx,
+ "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
+ identity.Name, cred.AccessKey, cred.SecretKey)
+ if err != nil {
+ return fmt.Errorf("failed to insert credential: %v", err)
+ }
+ }
+
+ return tx.Commit()
+}
+
+func (store *PostgresStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
+ if !store.configured {
+ return nil, fmt.Errorf("store not configured")
+ }
+
+ var email string
+ var accountDataJSON, actionsJSON []byte
+
+ err := store.db.QueryRowContext(ctx,
+ "SELECT email, account_data, actions FROM users WHERE username = $1",
+ username).Scan(&email, &accountDataJSON, &actionsJSON)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, credential.ErrUserNotFound
+ }
+ return nil, fmt.Errorf("failed to query user: %v", err)
+ }
+
+ identity := &iam_pb.Identity{
+ Name: username,
+ }
+
+ // Parse account data
+ if len(accountDataJSON) > 0 {
+ if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal account data: %v", err)
+ }
+ }
+
+ // Parse actions
+ if len(actionsJSON) > 0 {
+ if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal actions: %v", err)
+ }
+ }
+
+ // Query credentials
+ rows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query credentials: %v", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var accessKey, secretKey string
+ if err := rows.Scan(&accessKey, &secretKey); err != nil {
+ return nil, fmt.Errorf("failed to scan credential: %v", err)
+ }
+
+ identity.Credentials = append(identity.Credentials, &iam_pb.Credential{
+ AccessKey: accessKey,
+ SecretKey: secretKey,
+ })
+ }
+
+ return identity, nil
+}
+
+func (store *PostgresStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
+ if !store.configured {
+ return fmt.Errorf("store not configured")
+ }
+
+ // Start transaction
+ tx, err := store.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("failed to begin transaction: %v", err)
+ }
+ defer tx.Rollback()
+
+ // Check if user exists
+ var count int
+ err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
+ if err != nil {
+ return fmt.Errorf("failed to check user existence: %v", err)
+ }
+ if count == 0 {
+ return credential.ErrUserNotFound
+ }
+
+ // Marshal account data
+ var accountDataJSON []byte
+ if identity.Account != nil {
+ accountDataJSON, err = json.Marshal(identity.Account)
+ if err != nil {
+ return fmt.Errorf("failed to marshal account data: %v", err)
+ }
+ }
+
+ // Marshal actions
+ var actionsJSON []byte
+ if identity.Actions != nil {
+ actionsJSON, err = json.Marshal(identity.Actions)
+ if err != nil {
+ return fmt.Errorf("failed to marshal actions: %v", err)
+ }
+ }
+
+ // Update user
+ _, err = tx.ExecContext(ctx,
+ "UPDATE users SET email = $2, account_data = $3, actions = $4, updated_at = CURRENT_TIMESTAMP WHERE username = $1",
+ username, "", accountDataJSON, actionsJSON)
+ if err != nil {
+ return fmt.Errorf("failed to update user: %v", err)
+ }
+
+ // Delete existing credentials
+ _, err = tx.ExecContext(ctx, "DELETE FROM credentials WHERE username = $1", username)
+ if err != nil {
+ return fmt.Errorf("failed to delete existing credentials: %v", err)
+ }
+
+ // Insert new credentials
+ for _, cred := range identity.Credentials {
+ _, err = tx.ExecContext(ctx,
+ "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
+ username, cred.AccessKey, cred.SecretKey)
+ if err != nil {
+ return fmt.Errorf("failed to insert credential: %v", err)
+ }
+ }
+
+ return tx.Commit()
+}
+
+func (store *PostgresStore) DeleteUser(ctx context.Context, username string) error {
+ if !store.configured {
+ return fmt.Errorf("store not configured")
+ }
+
+ result, err := store.db.ExecContext(ctx, "DELETE FROM users WHERE username = $1", username)
+ if err != nil {
+ return fmt.Errorf("failed to delete user: %v", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %v", err)
+ }
+
+ if rowsAffected == 0 {
+ return credential.ErrUserNotFound
+ }
+
+ return nil
+}
+
+func (store *PostgresStore) ListUsers(ctx context.Context) ([]string, error) {
+ if !store.configured {
+ return nil, fmt.Errorf("store not configured")
+ }
+
+ rows, err := store.db.QueryContext(ctx, "SELECT username FROM users ORDER BY username")
+ if err != nil {
+ return nil, fmt.Errorf("failed to query users: %v", err)
+ }
+ defer rows.Close()
+
+ var usernames []string
+ for rows.Next() {
+ var username string
+ if err := rows.Scan(&username); err != nil {
+ return nil, fmt.Errorf("failed to scan username: %v", err)
+ }
+ usernames = append(usernames, username)
+ }
+
+ return usernames, nil
+}
+
+func (store *PostgresStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
+ if !store.configured {
+ return nil, fmt.Errorf("store not configured")
+ }
+
+ var username string
+ err := store.db.QueryRowContext(ctx, "SELECT username FROM credentials WHERE access_key = $1", accessKey).Scan(&username)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, credential.ErrAccessKeyNotFound
+ }
+ return nil, fmt.Errorf("failed to query access key: %v", err)
+ }
+
+ return store.GetUser(ctx, username)
+}
+
+func (store *PostgresStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
+ if !store.configured {
+ return fmt.Errorf("store not configured")
+ }
+
+ // Check if user exists
+ var count int
+ err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
+ if err != nil {
+ return fmt.Errorf("failed to check user existence: %v", err)
+ }
+ if count == 0 {
+ return credential.ErrUserNotFound
+ }
+
+ // Insert credential
+ _, err = store.db.ExecContext(ctx,
+ "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
+ username, cred.AccessKey, cred.SecretKey)
+ if err != nil {
+ return fmt.Errorf("failed to insert credential: %v", err)
+ }
+
+ return nil
+}
+
+func (store *PostgresStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
+ if !store.configured {
+ return fmt.Errorf("store not configured")
+ }
+
+ result, err := store.db.ExecContext(ctx,
+ "DELETE FROM credentials WHERE username = $1 AND access_key = $2",
+ username, accessKey)
+ if err != nil {
+ return fmt.Errorf("failed to delete access key: %v", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %v", err)
+ }
+
+ if rowsAffected == 0 {
+ // Check if user exists
+ var count int
+ err = store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
+ if err != nil {
+ return fmt.Errorf("failed to check user existence: %v", err)
+ }
+ if count == 0 {
+ return credential.ErrUserNotFound
+ }
+ return credential.ErrAccessKeyNotFound
+ }
+
+ return nil
+}
diff --git a/weed/credential/postgres/postgres_policy.go b/weed/credential/postgres/postgres_policy.go
new file mode 100644
index 000000000..8be2b108c
--- /dev/null
+++ b/weed/credential/postgres/postgres_policy.go
@@ -0,0 +1,130 @@
+package postgres
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+)
+
+// GetPolicies retrieves all IAM policies from PostgreSQL
+func (store *PostgresStore) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) {
+ if !store.configured {
+ return nil, fmt.Errorf("store not configured")
+ }
+
+ policies := make(map[string]credential.PolicyDocument)
+
+ rows, err := store.db.QueryContext(ctx, "SELECT name, document FROM policies")
+ if err != nil {
+ return nil, fmt.Errorf("failed to query policies: %v", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var name string
+ var documentJSON []byte
+
+ if err := rows.Scan(&name, &documentJSON); err != nil {
+ return nil, fmt.Errorf("failed to scan policy row: %v", err)
+ }
+
+ var document credential.PolicyDocument
+ if err := json.Unmarshal(documentJSON, &document); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal policy document for %s: %v", name, err)
+ }
+
+ policies[name] = document
+ }
+
+ return policies, nil
+}
+
+// CreatePolicy creates a new IAM policy in PostgreSQL
+func (store *PostgresStore) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
+ if !store.configured {
+ return fmt.Errorf("store not configured")
+ }
+
+ documentJSON, err := json.Marshal(document)
+ if err != nil {
+ return fmt.Errorf("failed to marshal policy document: %v", err)
+ }
+
+ _, err = store.db.ExecContext(ctx,
+ "INSERT INTO policies (name, document) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET document = $2, updated_at = CURRENT_TIMESTAMP",
+ name, documentJSON)
+ if err != nil {
+ return fmt.Errorf("failed to insert policy: %v", err)
+ }
+
+ return nil
+}
+
+// UpdatePolicy updates an existing IAM policy in PostgreSQL
+func (store *PostgresStore) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
+ if !store.configured {
+ return fmt.Errorf("store not configured")
+ }
+
+ documentJSON, err := json.Marshal(document)
+ if err != nil {
+ return fmt.Errorf("failed to marshal policy document: %v", err)
+ }
+
+ result, err := store.db.ExecContext(ctx,
+ "UPDATE policies SET document = $2, updated_at = CURRENT_TIMESTAMP WHERE name = $1",
+ name, documentJSON)
+ if err != nil {
+ return fmt.Errorf("failed to update policy: %v", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %v", err)
+ }
+
+ if rowsAffected == 0 {
+ return fmt.Errorf("policy %s not found", name)
+ }
+
+ return nil
+}
+
+// DeletePolicy deletes an IAM policy from PostgreSQL
+func (store *PostgresStore) DeletePolicy(ctx context.Context, name string) error {
+ if !store.configured {
+ return fmt.Errorf("store not configured")
+ }
+
+ result, err := store.db.ExecContext(ctx, "DELETE FROM policies WHERE name = $1", name)
+ if err != nil {
+ return fmt.Errorf("failed to delete policy: %v", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %v", err)
+ }
+
+ if rowsAffected == 0 {
+ return fmt.Errorf("policy %s not found", name)
+ }
+
+ return nil
+}
+
+// GetPolicy retrieves a specific IAM policy by name from PostgreSQL
+func (store *PostgresStore) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) {
+ policies, err := store.GetPolicies(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ if policy, exists := policies[name]; exists {
+ return &policy, nil
+ }
+
+ return nil, nil // Policy not found
+}
diff --git a/weed/credential/postgres/postgres_store.go b/weed/credential/postgres/postgres_store.go
index 0d75ad8c0..40d200668 100644
--- a/weed/credential/postgres/postgres_store.go
+++ b/weed/credential/postgres/postgres_store.go
@@ -1,14 +1,11 @@
package postgres
import (
- "context"
"database/sql"
- "encoding/json"
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/credential"
- "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
_ "github.com/lib/pq"
@@ -114,6 +111,17 @@ func (store *PostgresStore) createTables() error {
CREATE INDEX IF NOT EXISTS idx_credentials_access_key ON credentials(access_key);
`
+ // Create policies table
+ policiesTable := `
+ CREATE TABLE IF NOT EXISTS policies (
+ name VARCHAR(255) PRIMARY KEY,
+ document JSONB NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE INDEX IF NOT EXISTS idx_policies_name ON policies(name);
+ `
+
// Execute table creation
if _, err := store.db.Exec(usersTable); err != nil {
return fmt.Errorf("failed to create users table: %v", err)
@@ -123,439 +131,8 @@ func (store *PostgresStore) createTables() error {
return fmt.Errorf("failed to create credentials table: %v", err)
}
- return nil
-}
-
-func (store *PostgresStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
- if !store.configured {
- return nil, fmt.Errorf("store not configured")
- }
-
- config := &iam_pb.S3ApiConfiguration{}
-
- // Query all users
- rows, err := store.db.QueryContext(ctx, "SELECT username, email, account_data, actions FROM users")
- if err != nil {
- return nil, fmt.Errorf("failed to query users: %v", err)
- }
- defer rows.Close()
-
- for rows.Next() {
- var username, email string
- var accountDataJSON, actionsJSON []byte
-
- if err := rows.Scan(&username, &email, &accountDataJSON, &actionsJSON); err != nil {
- return nil, fmt.Errorf("failed to scan user row: %v", err)
- }
-
- identity := &iam_pb.Identity{
- Name: username,
- }
-
- // Parse account data
- if len(accountDataJSON) > 0 {
- if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil {
- return nil, fmt.Errorf("failed to unmarshal account data for user %s: %v", username, err)
- }
- }
-
- // Parse actions
- if len(actionsJSON) > 0 {
- if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil {
- return nil, fmt.Errorf("failed to unmarshal actions for user %s: %v", username, err)
- }
- }
-
- // Query credentials for this user
- credRows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username)
- if err != nil {
- return nil, fmt.Errorf("failed to query credentials for user %s: %v", username, err)
- }
-
- for credRows.Next() {
- var accessKey, secretKey string
- if err := credRows.Scan(&accessKey, &secretKey); err != nil {
- credRows.Close()
- return nil, fmt.Errorf("failed to scan credential row for user %s: %v", username, err)
- }
-
- identity.Credentials = append(identity.Credentials, &iam_pb.Credential{
- AccessKey: accessKey,
- SecretKey: secretKey,
- })
- }
- credRows.Close()
-
- config.Identities = append(config.Identities, identity)
- }
-
- return config, nil
-}
-
-func (store *PostgresStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
- if !store.configured {
- return fmt.Errorf("store not configured")
- }
-
- // Start transaction
- tx, err := store.db.BeginTx(ctx, nil)
- if err != nil {
- return fmt.Errorf("failed to begin transaction: %v", err)
- }
- defer tx.Rollback()
-
- // Clear existing data
- if _, err := tx.ExecContext(ctx, "DELETE FROM credentials"); err != nil {
- return fmt.Errorf("failed to clear credentials: %v", err)
- }
- if _, err := tx.ExecContext(ctx, "DELETE FROM users"); err != nil {
- return fmt.Errorf("failed to clear users: %v", err)
- }
-
- // Insert all identities
- for _, identity := range config.Identities {
- // Marshal account data
- var accountDataJSON []byte
- if identity.Account != nil {
- accountDataJSON, err = json.Marshal(identity.Account)
- if err != nil {
- return fmt.Errorf("failed to marshal account data for user %s: %v", identity.Name, err)
- }
- }
-
- // Marshal actions
- var actionsJSON []byte
- if identity.Actions != nil {
- actionsJSON, err = json.Marshal(identity.Actions)
- if err != nil {
- return fmt.Errorf("failed to marshal actions for user %s: %v", identity.Name, err)
- }
- }
-
- // Insert user
- _, err := tx.ExecContext(ctx,
- "INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)",
- identity.Name, "", accountDataJSON, actionsJSON)
- if err != nil {
- return fmt.Errorf("failed to insert user %s: %v", identity.Name, err)
- }
-
- // Insert credentials
- for _, cred := range identity.Credentials {
- _, err := tx.ExecContext(ctx,
- "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
- identity.Name, cred.AccessKey, cred.SecretKey)
- if err != nil {
- return fmt.Errorf("failed to insert credential for user %s: %v", identity.Name, err)
- }
- }
- }
-
- return tx.Commit()
-}
-
-func (store *PostgresStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
- if !store.configured {
- return fmt.Errorf("store not configured")
- }
-
- // Check if user already exists
- var count int
- err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", identity.Name).Scan(&count)
- if err != nil {
- return fmt.Errorf("failed to check user existence: %v", err)
- }
- if count > 0 {
- return credential.ErrUserAlreadyExists
- }
-
- // Start transaction
- tx, err := store.db.BeginTx(ctx, nil)
- if err != nil {
- return fmt.Errorf("failed to begin transaction: %v", err)
- }
- defer tx.Rollback()
-
- // Marshal account data
- var accountDataJSON []byte
- if identity.Account != nil {
- accountDataJSON, err = json.Marshal(identity.Account)
- if err != nil {
- return fmt.Errorf("failed to marshal account data: %v", err)
- }
- }
-
- // Marshal actions
- var actionsJSON []byte
- if identity.Actions != nil {
- actionsJSON, err = json.Marshal(identity.Actions)
- if err != nil {
- return fmt.Errorf("failed to marshal actions: %v", err)
- }
- }
-
- // Insert user
- _, err = tx.ExecContext(ctx,
- "INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)",
- identity.Name, "", accountDataJSON, actionsJSON)
- if err != nil {
- return fmt.Errorf("failed to insert user: %v", err)
- }
-
- // Insert credentials
- for _, cred := range identity.Credentials {
- _, err = tx.ExecContext(ctx,
- "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
- identity.Name, cred.AccessKey, cred.SecretKey)
- if err != nil {
- return fmt.Errorf("failed to insert credential: %v", err)
- }
- }
-
- return tx.Commit()
-}
-
-func (store *PostgresStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
- if !store.configured {
- return nil, fmt.Errorf("store not configured")
- }
-
- var email string
- var accountDataJSON, actionsJSON []byte
-
- err := store.db.QueryRowContext(ctx,
- "SELECT email, account_data, actions FROM users WHERE username = $1",
- username).Scan(&email, &accountDataJSON, &actionsJSON)
- if err != nil {
- if err == sql.ErrNoRows {
- return nil, credential.ErrUserNotFound
- }
- return nil, fmt.Errorf("failed to query user: %v", err)
- }
-
- identity := &iam_pb.Identity{
- Name: username,
- }
-
- // Parse account data
- if len(accountDataJSON) > 0 {
- if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil {
- return nil, fmt.Errorf("failed to unmarshal account data: %v", err)
- }
- }
-
- // Parse actions
- if len(actionsJSON) > 0 {
- if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil {
- return nil, fmt.Errorf("failed to unmarshal actions: %v", err)
- }
- }
-
- // Query credentials
- rows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username)
- if err != nil {
- return nil, fmt.Errorf("failed to query credentials: %v", err)
- }
- defer rows.Close()
-
- for rows.Next() {
- var accessKey, secretKey string
- if err := rows.Scan(&accessKey, &secretKey); err != nil {
- return nil, fmt.Errorf("failed to scan credential: %v", err)
- }
-
- identity.Credentials = append(identity.Credentials, &iam_pb.Credential{
- AccessKey: accessKey,
- SecretKey: secretKey,
- })
- }
-
- return identity, nil
-}
-
-func (store *PostgresStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
- if !store.configured {
- return fmt.Errorf("store not configured")
- }
-
- // Start transaction
- tx, err := store.db.BeginTx(ctx, nil)
- if err != nil {
- return fmt.Errorf("failed to begin transaction: %v", err)
- }
- defer tx.Rollback()
-
- // Check if user exists
- var count int
- err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
- if err != nil {
- return fmt.Errorf("failed to check user existence: %v", err)
- }
- if count == 0 {
- return credential.ErrUserNotFound
- }
-
- // Marshal account data
- var accountDataJSON []byte
- if identity.Account != nil {
- accountDataJSON, err = json.Marshal(identity.Account)
- if err != nil {
- return fmt.Errorf("failed to marshal account data: %v", err)
- }
- }
-
- // Marshal actions
- var actionsJSON []byte
- if identity.Actions != nil {
- actionsJSON, err = json.Marshal(identity.Actions)
- if err != nil {
- return fmt.Errorf("failed to marshal actions: %v", err)
- }
- }
-
- // Update user
- _, err = tx.ExecContext(ctx,
- "UPDATE users SET email = $2, account_data = $3, actions = $4, updated_at = CURRENT_TIMESTAMP WHERE username = $1",
- username, "", accountDataJSON, actionsJSON)
- if err != nil {
- return fmt.Errorf("failed to update user: %v", err)
- }
-
- // Delete existing credentials
- _, err = tx.ExecContext(ctx, "DELETE FROM credentials WHERE username = $1", username)
- if err != nil {
- return fmt.Errorf("failed to delete existing credentials: %v", err)
- }
-
- // Insert new credentials
- for _, cred := range identity.Credentials {
- _, err = tx.ExecContext(ctx,
- "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
- username, cred.AccessKey, cred.SecretKey)
- if err != nil {
- return fmt.Errorf("failed to insert credential: %v", err)
- }
- }
-
- return tx.Commit()
-}
-
-func (store *PostgresStore) DeleteUser(ctx context.Context, username string) error {
- if !store.configured {
- return fmt.Errorf("store not configured")
- }
-
- result, err := store.db.ExecContext(ctx, "DELETE FROM users WHERE username = $1", username)
- if err != nil {
- return fmt.Errorf("failed to delete user: %v", err)
- }
-
- rowsAffected, err := result.RowsAffected()
- if err != nil {
- return fmt.Errorf("failed to get rows affected: %v", err)
- }
-
- if rowsAffected == 0 {
- return credential.ErrUserNotFound
- }
-
- return nil
-}
-
-func (store *PostgresStore) ListUsers(ctx context.Context) ([]string, error) {
- if !store.configured {
- return nil, fmt.Errorf("store not configured")
- }
-
- rows, err := store.db.QueryContext(ctx, "SELECT username FROM users ORDER BY username")
- if err != nil {
- return nil, fmt.Errorf("failed to query users: %v", err)
- }
- defer rows.Close()
-
- var usernames []string
- for rows.Next() {
- var username string
- if err := rows.Scan(&username); err != nil {
- return nil, fmt.Errorf("failed to scan username: %v", err)
- }
- usernames = append(usernames, username)
- }
-
- return usernames, nil
-}
-
-func (store *PostgresStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
- if !store.configured {
- return nil, fmt.Errorf("store not configured")
- }
-
- var username string
- err := store.db.QueryRowContext(ctx, "SELECT username FROM credentials WHERE access_key = $1", accessKey).Scan(&username)
- if err != nil {
- if err == sql.ErrNoRows {
- return nil, credential.ErrAccessKeyNotFound
- }
- return nil, fmt.Errorf("failed to query access key: %v", err)
- }
-
- return store.GetUser(ctx, username)
-}
-
-func (store *PostgresStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
- if !store.configured {
- return fmt.Errorf("store not configured")
- }
-
- // Check if user exists
- var count int
- err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
- if err != nil {
- return fmt.Errorf("failed to check user existence: %v", err)
- }
- if count == 0 {
- return credential.ErrUserNotFound
- }
-
- // Insert credential
- _, err = store.db.ExecContext(ctx,
- "INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
- username, cred.AccessKey, cred.SecretKey)
- if err != nil {
- return fmt.Errorf("failed to insert credential: %v", err)
- }
-
- return nil
-}
-
-func (store *PostgresStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
- if !store.configured {
- return fmt.Errorf("store not configured")
- }
-
- result, err := store.db.ExecContext(ctx,
- "DELETE FROM credentials WHERE username = $1 AND access_key = $2",
- username, accessKey)
- if err != nil {
- return fmt.Errorf("failed to delete access key: %v", err)
- }
-
- rowsAffected, err := result.RowsAffected()
- if err != nil {
- return fmt.Errorf("failed to get rows affected: %v", err)
- }
-
- if rowsAffected == 0 {
- // Check if user exists
- var count int
- err = store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
- if err != nil {
- return fmt.Errorf("failed to check user existence: %v", err)
- }
- if count == 0 {
- return credential.ErrUserNotFound
- }
- return credential.ErrAccessKeyNotFound
+ if _, err := store.db.Exec(policiesTable); err != nil {
+ return fmt.Errorf("failed to create policies table: %v", err)
}
return nil
diff --git a/weed/credential/test/policy_test.go b/weed/credential/test/policy_test.go
new file mode 100644
index 000000000..341a05003
--- /dev/null
+++ b/weed/credential/test/policy_test.go
@@ -0,0 +1,146 @@
+package test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
+
+ // Import all store implementations to register them
+ _ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc"
+ _ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
+ _ "github.com/seaweedfs/seaweedfs/weed/credential/postgres"
+)
+
+// TestPolicyManagement tests policy management across all credential stores
+func TestPolicyManagement(t *testing.T) {
+ ctx := context.Background()
+
+ // Test with memory store (easiest to test)
+ credentialManager, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "")
+ if err != nil {
+ t.Fatalf("Failed to create credential manager: %v", err)
+ }
+
+ // Test policy operations
+ testPolicyOperations(t, ctx, credentialManager)
+}
+
+func testPolicyOperations(t *testing.T, ctx context.Context, credentialManager *credential.CredentialManager) {
+ store := credentialManager.GetStore()
+
+ // Cast to memory store to access policy methods
+ memoryStore, ok := store.(*memory.MemoryStore)
+ if !ok {
+ t.Skip("Store is not a memory store")
+ }
+
+ // Test GetPolicies (should be empty initially)
+ policies, err := memoryStore.GetPolicies(ctx)
+ if err != nil {
+ t.Fatalf("Failed to get policies: %v", err)
+ }
+ if len(policies) != 0 {
+ t.Errorf("Expected 0 policies, got %d", len(policies))
+ }
+
+ // Test CreatePolicy
+ testPolicy := credential.PolicyDocument{
+ Version: "2012-10-17",
+ Statement: []*credential.PolicyStatement{
+ {
+ Effect: "Allow",
+ Action: []string{"s3:GetObject"},
+ Resource: []string{"arn:aws:s3:::test-bucket/*"},
+ },
+ },
+ }
+
+ err = memoryStore.CreatePolicy(ctx, "test-policy", testPolicy)
+ if err != nil {
+ t.Fatalf("Failed to create policy: %v", err)
+ }
+
+ // Test GetPolicies (should have 1 policy now)
+ policies, err = memoryStore.GetPolicies(ctx)
+ if err != nil {
+ t.Fatalf("Failed to get policies: %v", err)
+ }
+ if len(policies) != 1 {
+ t.Errorf("Expected 1 policy, got %d", len(policies))
+ }
+
+ // Verify policy content
+ policy, exists := policies["test-policy"]
+ if !exists {
+ t.Error("test-policy not found")
+ }
+ if policy.Version != "2012-10-17" {
+ t.Errorf("Expected policy version '2012-10-17', got '%s'", policy.Version)
+ }
+ if len(policy.Statement) != 1 {
+ t.Errorf("Expected 1 statement, got %d", len(policy.Statement))
+ }
+
+ // Test UpdatePolicy
+ updatedPolicy := credential.PolicyDocument{
+ Version: "2012-10-17",
+ Statement: []*credential.PolicyStatement{
+ {
+ Effect: "Allow",
+ Action: []string{"s3:GetObject", "s3:PutObject"},
+ Resource: []string{"arn:aws:s3:::test-bucket/*"},
+ },
+ },
+ }
+
+ err = memoryStore.UpdatePolicy(ctx, "test-policy", updatedPolicy)
+ if err != nil {
+ t.Fatalf("Failed to update policy: %v", err)
+ }
+
+ // Verify the update
+ policies, err = memoryStore.GetPolicies(ctx)
+ if err != nil {
+ t.Fatalf("Failed to get policies after update: %v", err)
+ }
+
+ updatedPolicyResult, exists := policies["test-policy"]
+ if !exists {
+ t.Error("test-policy not found after update")
+ }
+ if len(updatedPolicyResult.Statement) != 1 {
+ t.Errorf("Expected 1 statement after update, got %d", len(updatedPolicyResult.Statement))
+ }
+ if len(updatedPolicyResult.Statement[0].Action) != 2 {
+ t.Errorf("Expected 2 actions after update, got %d", len(updatedPolicyResult.Statement[0].Action))
+ }
+
+ // Test DeletePolicy
+ err = memoryStore.DeletePolicy(ctx, "test-policy")
+ if err != nil {
+ t.Fatalf("Failed to delete policy: %v", err)
+ }
+
+ // Verify deletion
+ policies, err = memoryStore.GetPolicies(ctx)
+ if err != nil {
+ t.Fatalf("Failed to get policies after deletion: %v", err)
+ }
+ if len(policies) != 0 {
+ t.Errorf("Expected 0 policies after deletion, got %d", len(policies))
+ }
+}
+
+// TestPolicyManagementWithFilerEtc tests policy management with filer_etc store
+func TestPolicyManagementWithFilerEtc(t *testing.T) {
+ // Skip this test if we can't connect to a filer
+ t.Skip("Filer connection required for filer_etc store testing")
+}
+
+// TestPolicyManagementWithPostgres tests policy management with postgres store
+func TestPolicyManagementWithPostgres(t *testing.T) {
+ // Skip this test if we can't connect to PostgreSQL
+ t.Skip("PostgreSQL connection required for postgres store testing")
+}
diff --git a/weed/worker/tasks/balance/ui_templ.go b/weed/worker/tasks/balance/ui_templ.go
deleted file mode 100644
index 54998af4c..000000000
--- a/weed/worker/tasks/balance/ui_templ.go
+++ /dev/null
@@ -1,369 +0,0 @@
-package balance
-
-import (
- "fmt"
- "strconv"
- "time"
-
- "github.com/seaweedfs/seaweedfs/weed/admin/view/components"
- "github.com/seaweedfs/seaweedfs/weed/glog"
- "github.com/seaweedfs/seaweedfs/weed/worker/types"
-)
-
-// Helper function to format seconds as duration string
-func formatDurationFromSeconds(seconds int) string {
- d := time.Duration(seconds) * time.Second
- return d.String()
-}
-
-// Helper functions to convert between seconds and value+unit format
-func secondsToValueAndUnit(seconds int) (float64, string) {
- if seconds == 0 {
- return 0, "minutes"
- }
-
- // Try days first
- if seconds%(24*3600) == 0 && seconds >= 24*3600 {
- return float64(seconds / (24 * 3600)), "days"
- }
-
- // Try hours
- if seconds%3600 == 0 && seconds >= 3600 {
- return float64(seconds / 3600), "hours"
- }
-
- // Default to minutes
- return float64(seconds / 60), "minutes"
-}
-
-func valueAndUnitToSeconds(value float64, unit string) int {
- switch unit {
- case "days":
- return int(value * 24 * 3600)
- case "hours":
- return int(value * 3600)
- case "minutes":
- return int(value * 60)
- default:
- return int(value * 60) // Default to minutes
- }
-}
-
-// UITemplProvider provides the templ-based UI for balance task configuration
-type UITemplProvider struct {
- detector *BalanceDetector
- scheduler *BalanceScheduler
-}
-
-// NewUITemplProvider creates a new balance templ UI provider
-func NewUITemplProvider(detector *BalanceDetector, scheduler *BalanceScheduler) *UITemplProvider {
- return &UITemplProvider{
- detector: detector,
- scheduler: scheduler,
- }
-}
-
-// GetTaskType returns the task type
-func (ui *UITemplProvider) GetTaskType() types.TaskType {
- return types.TaskTypeBalance
-}
-
-// GetDisplayName returns the human-readable name
-func (ui *UITemplProvider) GetDisplayName() string {
- return "Volume Balance"
-}
-
-// GetDescription returns a description of what this task does
-func (ui *UITemplProvider) GetDescription() string {
- return "Redistributes volumes across volume servers to optimize storage utilization and performance"
-}
-
-// GetIcon returns the icon CSS class for this task type
-func (ui *UITemplProvider) GetIcon() string {
- return "fas fa-balance-scale text-secondary"
-}
-
-// RenderConfigSections renders the configuration as templ section data
-func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) {
- config := ui.getCurrentBalanceConfig()
-
- // Detection settings section
- detectionSection := components.ConfigSectionData{
- Title: "Detection Settings",
- Icon: "fas fa-search",
- Description: "Configure when balance tasks should be triggered",
- Fields: []interface{}{
- components.CheckboxFieldData{
- FormFieldData: components.FormFieldData{
- Name: "enabled",
- Label: "Enable Balance Tasks",
- Description: "Whether balance tasks should be automatically created",
- },
- Checked: config.Enabled,
- },
- components.NumberFieldData{
- FormFieldData: components.FormFieldData{
- Name: "imbalance_threshold",
- Label: "Imbalance Threshold",
- Description: "Trigger balance when storage imbalance exceeds this percentage (0.0-1.0)",
- Required: true,
- },
- Value: config.ImbalanceThreshold,
- Step: "0.01",
- Min: floatPtr(0.0),
- Max: floatPtr(1.0),
- },
- components.DurationInputFieldData{
- FormFieldData: components.FormFieldData{
- Name: "scan_interval",
- Label: "Scan Interval",
- Description: "How often to scan for imbalanced volumes",
- Required: true,
- },
- Seconds: config.ScanIntervalSeconds,
- },
- },
- }
-
- // Scheduling settings section
- schedulingSection := components.ConfigSectionData{
- Title: "Scheduling Settings",
- Icon: "fas fa-clock",
- Description: "Configure task scheduling and concurrency",
- Fields: []interface{}{
- components.NumberFieldData{
- FormFieldData: components.FormFieldData{
- Name: "max_concurrent",
- Label: "Max Concurrent Tasks",
- Description: "Maximum number of balance tasks that can run simultaneously",
- Required: true,
- },
- Value: float64(config.MaxConcurrent),
- Step: "1",
- Min: floatPtr(1),
- },
- components.NumberFieldData{
- FormFieldData: components.FormFieldData{
- Name: "min_server_count",
- Label: "Minimum Server Count",
- Description: "Only balance when at least this many servers are available",
- Required: true,
- },
- Value: float64(config.MinServerCount),
- Step: "1",
- Min: floatPtr(1),
- },
- },
- }
-
- // Timing constraints section
- timingSection := components.ConfigSectionData{
- Title: "Timing Constraints",
- Icon: "fas fa-calendar-clock",
- Description: "Configure when balance operations are allowed",
- Fields: []interface{}{
- components.CheckboxFieldData{
- FormFieldData: components.FormFieldData{
- Name: "move_during_off_hours",
- Label: "Restrict to Off-Hours",
- Description: "Only perform balance operations during off-peak hours",
- },
- Checked: config.MoveDuringOffHours,
- },
- components.TextFieldData{
- FormFieldData: components.FormFieldData{
- Name: "off_hours_start",
- Label: "Off-Hours Start Time",
- Description: "Start time for off-hours window (e.g., 23:00)",
- },
- Value: config.OffHoursStart,
- },
- components.TextFieldData{
- FormFieldData: components.FormFieldData{
- Name: "off_hours_end",
- Label: "Off-Hours End Time",
- Description: "End time for off-hours window (e.g., 06:00)",
- },
- Value: config.OffHoursEnd,
- },
- },
- }
-
- // Performance impact info section
- performanceSection := components.ConfigSectionData{
- Title: "Performance Considerations",
- Icon: "fas fa-exclamation-triangle",
- Description: "Important information about balance operations",
- Fields: []interface{}{
- components.TextFieldData{
- FormFieldData: components.FormFieldData{
- Name: "performance_info",
- Label: "Performance Impact",
- Description: "Volume balancing involves data movement and can impact cluster performance",
- },
- Value: "Enable off-hours restriction to minimize impact on production workloads",
- },
- components.TextFieldData{
- FormFieldData: components.FormFieldData{
- Name: "safety_info",
- Label: "Safety Requirements",
- Description: fmt.Sprintf("Requires at least %d servers to ensure data safety during moves", config.MinServerCount),
- },
- Value: "Maintains data safety during volume moves between servers",
- },
- },
- }
-
- return []components.ConfigSectionData{detectionSection, schedulingSection, timingSection, performanceSection}, nil
-}
-
-// ParseConfigForm parses form data into configuration
-func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) {
- config := &BalanceConfig{}
-
- // Parse enabled checkbox
- config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on"
-
- // Parse imbalance threshold
- if thresholdStr := formData["imbalance_threshold"]; len(thresholdStr) > 0 {
- if threshold, err := strconv.ParseFloat(thresholdStr[0], 64); err != nil {
- return nil, fmt.Errorf("invalid imbalance threshold: %v", err)
- } else if threshold < 0 || threshold > 1 {
- return nil, fmt.Errorf("imbalance threshold must be between 0.0 and 1.0")
- } else {
- config.ImbalanceThreshold = threshold
- }
- }
-
- // Parse scan interval
- if valueStr := formData["scan_interval"]; len(valueStr) > 0 {
- if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
- return nil, fmt.Errorf("invalid scan interval value: %v", err)
- } else {
- unit := "minutes" // default
- if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 {
- unit = unitStr[0]
- }
- config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit)
- }
- }
-
- // Parse max concurrent
- if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 {
- if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil {
- return nil, fmt.Errorf("invalid max concurrent: %v", err)
- } else if concurrent < 1 {
- return nil, fmt.Errorf("max concurrent must be at least 1")
- } else {
- config.MaxConcurrent = concurrent
- }
- }
-
- // Parse min server count
- if serverCountStr := formData["min_server_count"]; len(serverCountStr) > 0 {
- if serverCount, err := strconv.Atoi(serverCountStr[0]); err != nil {
- return nil, fmt.Errorf("invalid min server count: %v", err)
- } else if serverCount < 1 {
- return nil, fmt.Errorf("min server count must be at least 1")
- } else {
- config.MinServerCount = serverCount
- }
- }
-
- // Parse move during off hours
- config.MoveDuringOffHours = len(formData["move_during_off_hours"]) > 0 && formData["move_during_off_hours"][0] == "on"
-
- // Parse off hours start time
- if startStr := formData["off_hours_start"]; len(startStr) > 0 {
- config.OffHoursStart = startStr[0]
- }
-
- // Parse off hours end time
- if endStr := formData["off_hours_end"]; len(endStr) > 0 {
- config.OffHoursEnd = endStr[0]
- }
-
- return config, nil
-}
-
-// GetCurrentConfig returns the current configuration
-func (ui *UITemplProvider) GetCurrentConfig() interface{} {
- return ui.getCurrentBalanceConfig()
-}
-
-// ApplyConfig applies the new configuration
-func (ui *UITemplProvider) ApplyConfig(config interface{}) error {
- balanceConfig, ok := config.(*BalanceConfig)
- if !ok {
- return fmt.Errorf("invalid config type, expected *BalanceConfig")
- }
-
- // Apply to detector
- if ui.detector != nil {
- ui.detector.SetEnabled(balanceConfig.Enabled)
- ui.detector.SetThreshold(balanceConfig.ImbalanceThreshold)
- ui.detector.SetMinCheckInterval(time.Duration(balanceConfig.ScanIntervalSeconds) * time.Second)
- }
-
- // Apply to scheduler
- if ui.scheduler != nil {
- ui.scheduler.SetEnabled(balanceConfig.Enabled)
- ui.scheduler.SetMaxConcurrent(balanceConfig.MaxConcurrent)
- ui.scheduler.SetMinServerCount(balanceConfig.MinServerCount)
- ui.scheduler.SetMoveDuringOffHours(balanceConfig.MoveDuringOffHours)
- ui.scheduler.SetOffHoursStart(balanceConfig.OffHoursStart)
- ui.scheduler.SetOffHoursEnd(balanceConfig.OffHoursEnd)
- }
-
- glog.V(1).Infof("Applied balance configuration: enabled=%v, threshold=%.1f%%, max_concurrent=%d, min_servers=%d, off_hours=%v",
- balanceConfig.Enabled, balanceConfig.ImbalanceThreshold*100, balanceConfig.MaxConcurrent,
- balanceConfig.MinServerCount, balanceConfig.MoveDuringOffHours)
-
- return nil
-}
-
-// getCurrentBalanceConfig gets the current configuration from detector and scheduler
-func (ui *UITemplProvider) getCurrentBalanceConfig() *BalanceConfig {
- config := &BalanceConfig{
- // Default values (fallback if detectors/schedulers are nil)
- Enabled: true,
- ImbalanceThreshold: 0.1, // 10% imbalance
- ScanIntervalSeconds: int((4 * time.Hour).Seconds()),
- MaxConcurrent: 1,
- MinServerCount: 3,
- MoveDuringOffHours: true,
- OffHoursStart: "23:00",
- OffHoursEnd: "06:00",
- }
-
- // Get current values from detector
- if ui.detector != nil {
- config.Enabled = ui.detector.IsEnabled()
- config.ImbalanceThreshold = ui.detector.GetThreshold()
- config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds())
- }
-
- // Get current values from scheduler
- if ui.scheduler != nil {
- config.MaxConcurrent = ui.scheduler.GetMaxConcurrent()
- config.MinServerCount = ui.scheduler.GetMinServerCount()
- config.MoveDuringOffHours = ui.scheduler.GetMoveDuringOffHours()
- config.OffHoursStart = ui.scheduler.GetOffHoursStart()
- config.OffHoursEnd = ui.scheduler.GetOffHoursEnd()
- }
-
- return config
-}
-
-// floatPtr is a helper function to create float64 pointers
-func floatPtr(f float64) *float64 {
- return &f
-}
-
-// RegisterUITempl registers the balance templ UI provider with the UI registry
-func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *BalanceDetector, scheduler *BalanceScheduler) {
- uiProvider := NewUITemplProvider(detector, scheduler)
- uiRegistry.RegisterUI(uiProvider)
-
- glog.V(1).Infof("✅ Registered balance task templ UI provider")
-}
diff --git a/weed/worker/tasks/erasure_coding/ui_templ.go b/weed/worker/tasks/erasure_coding/ui_templ.go
deleted file mode 100644
index 12c3d199e..000000000
--- a/weed/worker/tasks/erasure_coding/ui_templ.go
+++ /dev/null
@@ -1,319 +0,0 @@
-package erasure_coding
-
-import (
- "fmt"
- "strconv"
- "time"
-
- "github.com/seaweedfs/seaweedfs/weed/admin/view/components"
- "github.com/seaweedfs/seaweedfs/weed/glog"
- "github.com/seaweedfs/seaweedfs/weed/worker/types"
-)
-
-// Helper function to format seconds as duration string
-func formatDurationFromSeconds(seconds int) string {
- d := time.Duration(seconds) * time.Second
- return d.String()
-}
-
-// Helper function to convert value and unit to seconds
-func valueAndUnitToSeconds(value float64, unit string) int {
- switch unit {
- case "days":
- return int(value * 24 * 60 * 60)
- case "hours":
- return int(value * 60 * 60)
- case "minutes":
- return int(value * 60)
- default:
- return int(value * 60) // Default to minutes
- }
-}
-
-// UITemplProvider provides the templ-based UI for erasure coding task configuration
-type UITemplProvider struct {
- detector *EcDetector
- scheduler *Scheduler
-}
-
-// NewUITemplProvider creates a new erasure coding templ UI provider
-func NewUITemplProvider(detector *EcDetector, scheduler *Scheduler) *UITemplProvider {
- return &UITemplProvider{
- detector: detector,
- scheduler: scheduler,
- }
-}
-
-// ErasureCodingConfig is defined in ui.go - we reuse it
-
-// GetTaskType returns the task type
-func (ui *UITemplProvider) GetTaskType() types.TaskType {
- return types.TaskTypeErasureCoding
-}
-
-// GetDisplayName returns the human-readable name
-func (ui *UITemplProvider) GetDisplayName() string {
- return "Erasure Coding"
-}
-
-// GetDescription returns a description of what this task does
-func (ui *UITemplProvider) GetDescription() string {
- return "Converts replicated volumes to erasure-coded format for efficient storage"
-}
-
-// GetIcon returns the icon CSS class for this task type
-func (ui *UITemplProvider) GetIcon() string {
- return "fas fa-shield-alt text-info"
-}
-
-// RenderConfigSections renders the configuration as templ section data
-func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) {
- config := ui.getCurrentECConfig()
-
- // Detection settings section
- detectionSection := components.ConfigSectionData{
- Title: "Detection Settings",
- Icon: "fas fa-search",
- Description: "Configure when erasure coding tasks should be triggered",
- Fields: []interface{}{
- components.CheckboxFieldData{
- FormFieldData: components.FormFieldData{
- Name: "enabled",
- Label: "Enable Erasure Coding Tasks",
- Description: "Whether erasure coding tasks should be automatically created",
- },
- Checked: config.Enabled,
- },
- components.DurationInputFieldData{
- FormFieldData: components.FormFieldData{
- Name: "scan_interval",
- Label: "Scan Interval",
- Description: "How often to scan for volumes needing erasure coding",
- Required: true,
- },
- Seconds: config.ScanIntervalSeconds,
- },
- components.DurationInputFieldData{
- FormFieldData: components.FormFieldData{
- Name: "volume_age_threshold",
- Label: "Volume Age Threshold",
- Description: "Only apply erasure coding to volumes older than this age",
- Required: true,
- },
- Seconds: config.VolumeAgeHoursSeconds,
- },
- },
- }
-
- // Erasure coding parameters section
- paramsSection := components.ConfigSectionData{
- Title: "Erasure Coding Parameters",
- Icon: "fas fa-cogs",
- Description: "Configure erasure coding scheme and performance",
- Fields: []interface{}{
- components.NumberFieldData{
- FormFieldData: components.FormFieldData{
- Name: "data_shards",
- Label: "Data Shards",
- Description: "Number of data shards in the erasure coding scheme",
- Required: true,
- },
- Value: float64(config.ShardCount),
- Step: "1",
- Min: floatPtr(1),
- Max: floatPtr(16),
- },
- components.NumberFieldData{
- FormFieldData: components.FormFieldData{
- Name: "parity_shards",
- Label: "Parity Shards",
- Description: "Number of parity shards (determines fault tolerance)",
- Required: true,
- },
- Value: float64(config.ParityCount),
- Step: "1",
- Min: floatPtr(1),
- Max: floatPtr(16),
- },
- components.NumberFieldData{
- FormFieldData: components.FormFieldData{
- Name: "max_concurrent",
- Label: "Max Concurrent Tasks",
- Description: "Maximum number of erasure coding tasks that can run simultaneously",
- Required: true,
- },
- Value: float64(config.MaxConcurrent),
- Step: "1",
- Min: floatPtr(1),
- },
- },
- }
-
- // Performance impact info section
- infoSection := components.ConfigSectionData{
- Title: "Performance Impact",
- Icon: "fas fa-info-circle",
- Description: "Important information about erasure coding operations",
- Fields: []interface{}{
- components.TextFieldData{
- FormFieldData: components.FormFieldData{
- Name: "durability_info",
- Label: "Durability",
- Description: fmt.Sprintf("With %d+%d configuration, can tolerate up to %d shard failures",
- config.ShardCount, config.ParityCount, config.ParityCount),
- },
- Value: "High durability with space efficiency",
- },
- components.TextFieldData{
- FormFieldData: components.FormFieldData{
- Name: "performance_info",
- Label: "Performance Note",
- Description: "Erasure coding is CPU and I/O intensive. Consider running during off-peak hours",
- },
- Value: "Schedule during low-traffic periods",
- },
- },
- }
-
- return []components.ConfigSectionData{detectionSection, paramsSection, infoSection}, nil
-}
-
-// ParseConfigForm parses form data into configuration
-func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) {
- config := &ErasureCodingConfig{}
-
- // Parse enabled checkbox
- config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on"
-
- // Parse volume age threshold
- if valueStr := formData["volume_age_threshold"]; len(valueStr) > 0 {
- if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
- return nil, fmt.Errorf("invalid volume age threshold value: %v", err)
- } else {
- unit := "hours" // default
- if unitStr := formData["volume_age_threshold_unit"]; len(unitStr) > 0 {
- unit = unitStr[0]
- }
- config.VolumeAgeHoursSeconds = valueAndUnitToSeconds(value, unit)
- }
- }
-
- // Parse scan interval
- if valueStr := formData["scan_interval"]; len(valueStr) > 0 {
- if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
- return nil, fmt.Errorf("invalid scan interval value: %v", err)
- } else {
- unit := "hours" // default
- if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 {
- unit = unitStr[0]
- }
- config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit)
- }
- }
-
- // Parse data shards
- if shardsStr := formData["data_shards"]; len(shardsStr) > 0 {
- if shards, err := strconv.Atoi(shardsStr[0]); err != nil {
- return nil, fmt.Errorf("invalid data shards: %v", err)
- } else if shards < 1 || shards > 16 {
- return nil, fmt.Errorf("data shards must be between 1 and 16")
- } else {
- config.ShardCount = shards
- }
- }
-
- // Parse parity shards
- if shardsStr := formData["parity_shards"]; len(shardsStr) > 0 {
- if shards, err := strconv.Atoi(shardsStr[0]); err != nil {
- return nil, fmt.Errorf("invalid parity shards: %v", err)
- } else if shards < 1 || shards > 16 {
- return nil, fmt.Errorf("parity shards must be between 1 and 16")
- } else {
- config.ParityCount = shards
- }
- }
-
- // Parse max concurrent
- if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 {
- if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil {
- return nil, fmt.Errorf("invalid max concurrent: %v", err)
- } else if concurrent < 1 {
- return nil, fmt.Errorf("max concurrent must be at least 1")
- } else {
- config.MaxConcurrent = concurrent
- }
- }
-
- return config, nil
-}
-
-// GetCurrentConfig returns the current configuration
-func (ui *UITemplProvider) GetCurrentConfig() interface{} {
- return ui.getCurrentECConfig()
-}
-
-// ApplyConfig applies the new configuration
-func (ui *UITemplProvider) ApplyConfig(config interface{}) error {
- ecConfig, ok := config.(*ErasureCodingConfig)
- if !ok {
- return fmt.Errorf("invalid config type, expected *ErasureCodingConfig")
- }
-
- // Apply to detector
- if ui.detector != nil {
- ui.detector.SetEnabled(ecConfig.Enabled)
- ui.detector.SetVolumeAgeHours(ecConfig.VolumeAgeHoursSeconds)
- ui.detector.SetScanInterval(time.Duration(ecConfig.ScanIntervalSeconds) * time.Second)
- }
-
- // Apply to scheduler
- if ui.scheduler != nil {
- ui.scheduler.SetMaxConcurrent(ecConfig.MaxConcurrent)
- ui.scheduler.SetEnabled(ecConfig.Enabled)
- }
-
- glog.V(1).Infof("Applied erasure coding configuration: enabled=%v, age_threshold=%ds, max_concurrent=%d",
- ecConfig.Enabled, ecConfig.VolumeAgeHoursSeconds, ecConfig.MaxConcurrent)
-
- return nil
-}
-
-// getCurrentECConfig gets the current configuration from detector and scheduler
-func (ui *UITemplProvider) getCurrentECConfig() *ErasureCodingConfig {
- config := &ErasureCodingConfig{
- // Default values (fallback if detectors/schedulers are nil)
- Enabled: true,
- VolumeAgeHoursSeconds: int((24 * time.Hour).Seconds()),
- ScanIntervalSeconds: int((2 * time.Hour).Seconds()),
- MaxConcurrent: 1,
- ShardCount: 10,
- ParityCount: 4,
- }
-
- // Get current values from detector
- if ui.detector != nil {
- config.Enabled = ui.detector.IsEnabled()
- config.VolumeAgeHoursSeconds = ui.detector.GetVolumeAgeHours()
- config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds())
- }
-
- // Get current values from scheduler
- if ui.scheduler != nil {
- config.MaxConcurrent = ui.scheduler.GetMaxConcurrent()
- }
-
- return config
-}
-
-// floatPtr is a helper function to create float64 pointers
-func floatPtr(f float64) *float64 {
- return &f
-}
-
-// RegisterUITempl registers the erasure coding templ UI provider with the UI registry
-func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *EcDetector, scheduler *Scheduler) {
- uiProvider := NewUITemplProvider(detector, scheduler)
- uiRegistry.RegisterUI(uiProvider)
-
- glog.V(1).Infof("✅ Registered erasure coding task templ UI provider")
-}
diff --git a/weed/worker/tasks/vacuum/ui_templ.go b/weed/worker/tasks/vacuum/ui_templ.go
deleted file mode 100644
index 15558b832..000000000
--- a/weed/worker/tasks/vacuum/ui_templ.go
+++ /dev/null
@@ -1,330 +0,0 @@
-package vacuum
-
-import (
- "fmt"
- "strconv"
- "time"
-
- "github.com/seaweedfs/seaweedfs/weed/admin/view/components"
- "github.com/seaweedfs/seaweedfs/weed/glog"
- "github.com/seaweedfs/seaweedfs/weed/worker/types"
-)
-
-// Helper function to format seconds as duration string
-func formatDurationFromSeconds(seconds int) string {
- d := time.Duration(seconds) * time.Second
- return d.String()
-}
-
-// Helper functions to convert between seconds and value+unit format
-func secondsToValueAndUnit(seconds int) (float64, string) {
- if seconds == 0 {
- return 0, "minutes"
- }
-
- // Try days first
- if seconds%(24*3600) == 0 && seconds >= 24*3600 {
- return float64(seconds / (24 * 3600)), "days"
- }
-
- // Try hours
- if seconds%3600 == 0 && seconds >= 3600 {
- return float64(seconds / 3600), "hours"
- }
-
- // Default to minutes
- return float64(seconds / 60), "minutes"
-}
-
-func valueAndUnitToSeconds(value float64, unit string) int {
- switch unit {
- case "days":
- return int(value * 24 * 3600)
- case "hours":
- return int(value * 3600)
- case "minutes":
- return int(value * 60)
- default:
- return int(value * 60) // Default to minutes
- }
-}
-
-// UITemplProvider provides the templ-based UI for vacuum task configuration
-type UITemplProvider struct {
- detector *VacuumDetector
- scheduler *VacuumScheduler
-}
-
-// NewUITemplProvider creates a new vacuum templ UI provider
-func NewUITemplProvider(detector *VacuumDetector, scheduler *VacuumScheduler) *UITemplProvider {
- return &UITemplProvider{
- detector: detector,
- scheduler: scheduler,
- }
-}
-
-// GetTaskType returns the task type
-func (ui *UITemplProvider) GetTaskType() types.TaskType {
- return types.TaskTypeVacuum
-}
-
-// GetDisplayName returns the human-readable name
-func (ui *UITemplProvider) GetDisplayName() string {
- return "Volume Vacuum"
-}
-
-// GetDescription returns a description of what this task does
-func (ui *UITemplProvider) GetDescription() string {
- return "Reclaims disk space by removing deleted files from volumes"
-}
-
-// GetIcon returns the icon CSS class for this task type
-func (ui *UITemplProvider) GetIcon() string {
- return "fas fa-broom text-primary"
-}
-
-// RenderConfigSections renders the configuration as templ section data
-func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) {
- config := ui.getCurrentVacuumConfig()
-
- // Detection settings section
- detectionSection := components.ConfigSectionData{
- Title: "Detection Settings",
- Icon: "fas fa-search",
- Description: "Configure when vacuum tasks should be triggered",
- Fields: []interface{}{
- components.CheckboxFieldData{
- FormFieldData: components.FormFieldData{
- Name: "enabled",
- Label: "Enable Vacuum Tasks",
- Description: "Whether vacuum tasks should be automatically created",
- },
- Checked: config.Enabled,
- },
- components.NumberFieldData{
- FormFieldData: components.FormFieldData{
- Name: "garbage_threshold",
- Label: "Garbage Threshold",
- Description: "Trigger vacuum when garbage ratio exceeds this percentage (0.0-1.0)",
- Required: true,
- },
- Value: config.GarbageThreshold,
- Step: "0.01",
- Min: floatPtr(0.0),
- Max: floatPtr(1.0),
- },
- components.DurationInputFieldData{
- FormFieldData: components.FormFieldData{
- Name: "scan_interval",
- Label: "Scan Interval",
- Description: "How often to scan for volumes needing vacuum",
- Required: true,
- },
- Seconds: config.ScanIntervalSeconds,
- },
- components.DurationInputFieldData{
- FormFieldData: components.FormFieldData{
- Name: "min_volume_age",
- Label: "Minimum Volume Age",
- Description: "Only vacuum volumes older than this duration",
- Required: true,
- },
- Seconds: config.MinVolumeAgeSeconds,
- },
- },
- }
-
- // Scheduling settings section
- schedulingSection := components.ConfigSectionData{
- Title: "Scheduling Settings",
- Icon: "fas fa-clock",
- Description: "Configure task scheduling and concurrency",
- Fields: []interface{}{
- components.NumberFieldData{
- FormFieldData: components.FormFieldData{
- Name: "max_concurrent",
- Label: "Max Concurrent Tasks",
- Description: "Maximum number of vacuum tasks that can run simultaneously",
- Required: true,
- },
- Value: float64(config.MaxConcurrent),
- Step: "1",
- Min: floatPtr(1),
- },
- components.DurationInputFieldData{
- FormFieldData: components.FormFieldData{
- Name: "min_interval",
- Label: "Minimum Interval",
- Description: "Minimum time between vacuum operations on the same volume",
- Required: true,
- },
- Seconds: config.MinIntervalSeconds,
- },
- },
- }
-
- // Performance impact info section
- performanceSection := components.ConfigSectionData{
- Title: "Performance Impact",
- Icon: "fas fa-exclamation-triangle",
- Description: "Important information about vacuum operations",
- Fields: []interface{}{
- components.TextFieldData{
- FormFieldData: components.FormFieldData{
- Name: "info_impact",
- Label: "Impact",
- Description: "Volume vacuum operations are I/O intensive and should be scheduled appropriately",
- },
- Value: "Configure thresholds and intervals based on your storage usage patterns",
- },
- },
- }
-
- return []components.ConfigSectionData{detectionSection, schedulingSection, performanceSection}, nil
-}
-
-// ParseConfigForm parses form data into configuration
-func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) {
- config := &VacuumConfig{}
-
- // Parse enabled checkbox
- config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on"
-
- // Parse garbage threshold
- if thresholdStr := formData["garbage_threshold"]; len(thresholdStr) > 0 {
- if threshold, err := strconv.ParseFloat(thresholdStr[0], 64); err != nil {
- return nil, fmt.Errorf("invalid garbage threshold: %v", err)
- } else if threshold < 0 || threshold > 1 {
- return nil, fmt.Errorf("garbage threshold must be between 0.0 and 1.0")
- } else {
- config.GarbageThreshold = threshold
- }
- }
-
- // Parse scan interval
- if valueStr := formData["scan_interval"]; len(valueStr) > 0 {
- if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
- return nil, fmt.Errorf("invalid scan interval value: %v", err)
- } else {
- unit := "minutes" // default
- if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 {
- unit = unitStr[0]
- }
- config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit)
- }
- }
-
- // Parse min volume age
- if valueStr := formData["min_volume_age"]; len(valueStr) > 0 {
- if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
- return nil, fmt.Errorf("invalid min volume age value: %v", err)
- } else {
- unit := "minutes" // default
- if unitStr := formData["min_volume_age_unit"]; len(unitStr) > 0 {
- unit = unitStr[0]
- }
- config.MinVolumeAgeSeconds = valueAndUnitToSeconds(value, unit)
- }
- }
-
- // Parse max concurrent
- if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 {
- if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil {
- return nil, fmt.Errorf("invalid max concurrent: %v", err)
- } else if concurrent < 1 {
- return nil, fmt.Errorf("max concurrent must be at least 1")
- } else {
- config.MaxConcurrent = concurrent
- }
- }
-
- // Parse min interval
- if valueStr := formData["min_interval"]; len(valueStr) > 0 {
- if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
- return nil, fmt.Errorf("invalid min interval value: %v", err)
- } else {
- unit := "minutes" // default
- if unitStr := formData["min_interval_unit"]; len(unitStr) > 0 {
- unit = unitStr[0]
- }
- config.MinIntervalSeconds = valueAndUnitToSeconds(value, unit)
- }
- }
-
- return config, nil
-}
-
-// GetCurrentConfig returns the current configuration
-func (ui *UITemplProvider) GetCurrentConfig() interface{} {
- return ui.getCurrentVacuumConfig()
-}
-
-// ApplyConfig applies the new configuration
-func (ui *UITemplProvider) ApplyConfig(config interface{}) error {
- vacuumConfig, ok := config.(*VacuumConfig)
- if !ok {
- return fmt.Errorf("invalid config type, expected *VacuumConfig")
- }
-
- // Apply to detector
- if ui.detector != nil {
- ui.detector.SetEnabled(vacuumConfig.Enabled)
- ui.detector.SetGarbageThreshold(vacuumConfig.GarbageThreshold)
- ui.detector.SetScanInterval(time.Duration(vacuumConfig.ScanIntervalSeconds) * time.Second)
- ui.detector.SetMinVolumeAge(time.Duration(vacuumConfig.MinVolumeAgeSeconds) * time.Second)
- }
-
- // Apply to scheduler
- if ui.scheduler != nil {
- ui.scheduler.SetEnabled(vacuumConfig.Enabled)
- ui.scheduler.SetMaxConcurrent(vacuumConfig.MaxConcurrent)
- ui.scheduler.SetMinInterval(time.Duration(vacuumConfig.MinIntervalSeconds) * time.Second)
- }
-
- glog.V(1).Infof("Applied vacuum configuration: enabled=%v, threshold=%.1f%%, scan_interval=%s, max_concurrent=%d",
- vacuumConfig.Enabled, vacuumConfig.GarbageThreshold*100, formatDurationFromSeconds(vacuumConfig.ScanIntervalSeconds), vacuumConfig.MaxConcurrent)
-
- return nil
-}
-
-// getCurrentVacuumConfig gets the current configuration from detector and scheduler
-func (ui *UITemplProvider) getCurrentVacuumConfig() *VacuumConfig {
- config := &VacuumConfig{
- // Default values (fallback if detectors/schedulers are nil)
- Enabled: true,
- GarbageThreshold: 0.3,
- ScanIntervalSeconds: int((30 * time.Minute).Seconds()),
- MinVolumeAgeSeconds: int((1 * time.Hour).Seconds()),
- MaxConcurrent: 2,
- MinIntervalSeconds: int((6 * time.Hour).Seconds()),
- }
-
- // Get current values from detector
- if ui.detector != nil {
- config.Enabled = ui.detector.IsEnabled()
- config.GarbageThreshold = ui.detector.GetGarbageThreshold()
- config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds())
- config.MinVolumeAgeSeconds = int(ui.detector.GetMinVolumeAge().Seconds())
- }
-
- // Get current values from scheduler
- if ui.scheduler != nil {
- config.MaxConcurrent = ui.scheduler.GetMaxConcurrent()
- config.MinIntervalSeconds = int(ui.scheduler.GetMinInterval().Seconds())
- }
-
- return config
-}
-
-// floatPtr is a helper function to create float64 pointers
-func floatPtr(f float64) *float64 {
- return &f
-}
-
-// RegisterUITempl registers the vacuum templ UI provider with the UI registry
-func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *VacuumDetector, scheduler *VacuumScheduler) {
- uiProvider := NewUITemplProvider(detector, scheduler)
- uiRegistry.RegisterUI(uiProvider)
-
- glog.V(1).Infof("✅ Registered vacuum task templ UI provider")
-}
diff --git a/weed/worker/types/task_ui_templ.go b/weed/worker/types/task_ui_templ.go
deleted file mode 100644
index 77e80b408..000000000
--- a/weed/worker/types/task_ui_templ.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package types
-
-import (
- "github.com/seaweedfs/seaweedfs/weed/admin/view/components"
-)
-
-// TaskUITemplProvider defines how tasks provide their configuration UI using templ components
-type TaskUITemplProvider interface {
- // GetTaskType returns the task type
- GetTaskType() TaskType
-
- // GetDisplayName returns the human-readable name
- GetDisplayName() string
-
- // GetDescription returns a description of what this task does
- GetDescription() string
-
- // GetIcon returns the icon CSS class or HTML for this task type
- GetIcon() string
-
- // RenderConfigSections renders the configuration as templ section data
- RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error)
-
- // ParseConfigForm parses form data into configuration
- ParseConfigForm(formData map[string][]string) (interface{}, error)
-
- // GetCurrentConfig returns the current configuration
- GetCurrentConfig() interface{}
-
- // ApplyConfig applies the new configuration
- ApplyConfig(config interface{}) error
-}
-
-// UITemplRegistry manages task UI providers that use templ components
-type UITemplRegistry struct {
- providers map[TaskType]TaskUITemplProvider
-}
-
-// NewUITemplRegistry creates a new templ-based UI registry
-func NewUITemplRegistry() *UITemplRegistry {
- return &UITemplRegistry{
- providers: make(map[TaskType]TaskUITemplProvider),
- }
-}
-
-// RegisterUI registers a task UI provider
-func (r *UITemplRegistry) RegisterUI(provider TaskUITemplProvider) {
- r.providers[provider.GetTaskType()] = provider
-}
-
-// GetProvider returns the UI provider for a task type
-func (r *UITemplRegistry) GetProvider(taskType TaskType) TaskUITemplProvider {
- return r.providers[taskType]
-}
-
-// GetAllProviders returns all registered UI providers
-func (r *UITemplRegistry) GetAllProviders() map[TaskType]TaskUITemplProvider {
- result := make(map[TaskType]TaskUITemplProvider)
- for k, v := range r.providers {
- result[k] = v
- }
- return result
-}