aboutsummaryrefslogtreecommitdiff
path: root/weed/admin
diff options
context:
space:
mode:
Diffstat (limited to 'weed/admin')
-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
26 files changed, 3478 insertions, 368 deletions
diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go
index 95bff6deb..9ae5c6ebd 100644
--- a/weed/admin/dash/admin_server.go
+++ b/weed/admin/dash/admin_server.go
@@ -94,6 +94,7 @@ func NewAdminServer(masterAddress string, templateFS http.FileSystem, dataDir st
glog.V(1).Infof("Set filer client for credential manager: %s", filerAddr)
break
}
+ glog.V(1).Infof("Waiting for filer discovery for credential manager...")
time.Sleep(5 * time.Second) // Retry every 5 seconds
}
}()
diff --git a/weed/admin/dash/file_browser_data.go b/weed/admin/dash/file_browser_data.go
index 3cb878718..6bb30c469 100644
--- a/weed/admin/dash/file_browser_data.go
+++ b/weed/admin/dash/file_browser_data.go
@@ -99,7 +99,7 @@ func (s *AdminServer) GetFileBrowser(path string) (*FileBrowserData, error) {
var ttlSec int32
if entry.Attributes != nil {
- mode = formatFileMode(entry.Attributes.FileMode)
+ mode = FormatFileMode(entry.Attributes.FileMode)
uid = entry.Attributes.Uid
gid = entry.Attributes.Gid
size = int64(entry.Attributes.FileSize)
@@ -270,81 +270,3 @@ func (s *AdminServer) generateBreadcrumbs(path string) []BreadcrumbItem {
return breadcrumbs
}
-
-// formatFileMode converts file mode to Unix-style string representation (e.g., "drwxr-xr-x")
-func formatFileMode(mode uint32) string {
- var result []byte = make([]byte, 10)
-
- // File type
- switch mode & 0170000 { // S_IFMT mask
- case 0040000: // S_IFDIR
- result[0] = 'd'
- case 0100000: // S_IFREG
- result[0] = '-'
- case 0120000: // S_IFLNK
- result[0] = 'l'
- case 0020000: // S_IFCHR
- result[0] = 'c'
- case 0060000: // S_IFBLK
- result[0] = 'b'
- case 0010000: // S_IFIFO
- result[0] = 'p'
- case 0140000: // S_IFSOCK
- result[0] = 's'
- default:
- result[0] = '-' // S_IFREG is default
- }
-
- // Owner permissions
- if mode&0400 != 0 { // S_IRUSR
- result[1] = 'r'
- } else {
- result[1] = '-'
- }
- if mode&0200 != 0 { // S_IWUSR
- result[2] = 'w'
- } else {
- result[2] = '-'
- }
- if mode&0100 != 0 { // S_IXUSR
- result[3] = 'x'
- } else {
- result[3] = '-'
- }
-
- // Group permissions
- if mode&0040 != 0 { // S_IRGRP
- result[4] = 'r'
- } else {
- result[4] = '-'
- }
- if mode&0020 != 0 { // S_IWGRP
- result[5] = 'w'
- } else {
- result[5] = '-'
- }
- if mode&0010 != 0 { // S_IXGRP
- result[6] = 'x'
- } else {
- result[6] = '-'
- }
-
- // Other permissions
- if mode&0004 != 0 { // S_IROTH
- result[7] = 'r'
- } else {
- result[7] = '-'
- }
- if mode&0002 != 0 { // S_IWOTH
- result[8] = 'w'
- } else {
- result[8] = '-'
- }
- if mode&0001 != 0 { // S_IXOTH
- result[9] = 'x'
- } else {
- result[9] = '-'
- }
-
- return string(result)
-}
diff --git a/weed/admin/dash/file_mode_utils.go b/weed/admin/dash/file_mode_utils.go
new file mode 100644
index 000000000..19c5b2f49
--- /dev/null
+++ b/weed/admin/dash/file_mode_utils.go
@@ -0,0 +1,85 @@
+package dash
+
+// FormatFileMode converts file mode to Unix-style string representation (e.g., "drwxr-xr-x")
+// Handles both Go's os.ModeDir format and standard Unix file type bits
+func FormatFileMode(mode uint32) string {
+ var result []byte = make([]byte, 10)
+
+ // File type - handle Go's os.ModeDir first, then standard Unix file type bits
+ if mode&0x80000000 != 0 { // Go's os.ModeDir (0x80000000 = 2147483648)
+ result[0] = 'd'
+ } else {
+ switch mode & 0170000 { // S_IFMT mask
+ case 0040000: // S_IFDIR
+ result[0] = 'd'
+ case 0100000: // S_IFREG
+ result[0] = '-'
+ case 0120000: // S_IFLNK
+ result[0] = 'l'
+ case 0020000: // S_IFCHR
+ result[0] = 'c'
+ case 0060000: // S_IFBLK
+ result[0] = 'b'
+ case 0010000: // S_IFIFO
+ result[0] = 'p'
+ case 0140000: // S_IFSOCK
+ result[0] = 's'
+ default:
+ result[0] = '-' // S_IFREG is default
+ }
+ }
+
+ // Permission bits (always use the lower 12 bits regardless of file type format)
+ // Owner permissions
+ if mode&0400 != 0 { // S_IRUSR
+ result[1] = 'r'
+ } else {
+ result[1] = '-'
+ }
+ if mode&0200 != 0 { // S_IWUSR
+ result[2] = 'w'
+ } else {
+ result[2] = '-'
+ }
+ if mode&0100 != 0 { // S_IXUSR
+ result[3] = 'x'
+ } else {
+ result[3] = '-'
+ }
+
+ // Group permissions
+ if mode&0040 != 0 { // S_IRGRP
+ result[4] = 'r'
+ } else {
+ result[4] = '-'
+ }
+ if mode&0020 != 0 { // S_IWGRP
+ result[5] = 'w'
+ } else {
+ result[5] = '-'
+ }
+ if mode&0010 != 0 { // S_IXGRP
+ result[6] = 'x'
+ } else {
+ result[6] = '-'
+ }
+
+ // Other permissions
+ if mode&0004 != 0 { // S_IROTH
+ result[7] = 'r'
+ } else {
+ result[7] = '-'
+ }
+ if mode&0002 != 0 { // S_IWOTH
+ result[8] = 'w'
+ } else {
+ result[8] = '-'
+ }
+ if mode&0001 != 0 { // S_IXOTH
+ result[9] = 'x'
+ } else {
+ result[9] = '-'
+ }
+
+ return string(result)
+}
diff --git a/weed/admin/dash/policies_management.go b/weed/admin/dash/policies_management.go
new file mode 100644
index 000000000..8853bbb54
--- /dev/null
+++ b/weed/admin/dash/policies_management.go
@@ -0,0 +1,225 @@
+package dash
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+)
+
+type IAMPolicy struct {
+ Name string `json:"name"`
+ Document credential.PolicyDocument `json:"document"`
+ DocumentJSON string `json:"document_json"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type PoliciesCollection struct {
+ Policies map[string]credential.PolicyDocument `json:"policies"`
+}
+
+type PoliciesData struct {
+ Username string `json:"username"`
+ Policies []IAMPolicy `json:"policies"`
+ TotalPolicies int `json:"total_policies"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+// Policy management request structures
+type CreatePolicyRequest struct {
+ Name string `json:"name" binding:"required"`
+ Document credential.PolicyDocument `json:"document" binding:"required"`
+ DocumentJSON string `json:"document_json"`
+}
+
+type UpdatePolicyRequest struct {
+ Document credential.PolicyDocument `json:"document" binding:"required"`
+ DocumentJSON string `json:"document_json"`
+}
+
+// PolicyManager interface is now in the credential package
+
+// CredentialStorePolicyManager implements credential.PolicyManager by delegating to the credential store
+type CredentialStorePolicyManager struct {
+ credentialManager *credential.CredentialManager
+}
+
+// NewCredentialStorePolicyManager creates a new CredentialStorePolicyManager
+func NewCredentialStorePolicyManager(credentialManager *credential.CredentialManager) *CredentialStorePolicyManager {
+ return &CredentialStorePolicyManager{
+ credentialManager: credentialManager,
+ }
+}
+
+// GetPolicies retrieves all IAM policies via credential store
+func (cspm *CredentialStorePolicyManager) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) {
+ // Get policies from credential store
+ // We'll use the credential store to access the filer indirectly
+ // Since policies are stored separately, we need to access the underlying store
+ store := cspm.credentialManager.GetStore()
+ glog.V(1).Infof("Getting policies from credential store: %T", store)
+
+ // Check if the store supports policy management
+ if policyStore, ok := store.(credential.PolicyManager); ok {
+ glog.V(1).Infof("Store supports policy management, calling GetPolicies")
+ policies, err := policyStore.GetPolicies(ctx)
+ if err != nil {
+ glog.Errorf("Error getting policies from store: %v", err)
+ return nil, err
+ }
+ glog.V(1).Infof("Got %d policies from store", len(policies))
+ return policies, nil
+ } else {
+ // Fallback: use empty policies for stores that don't support policies
+ glog.V(1).Infof("Credential store doesn't support policy management, returning empty policies")
+ return make(map[string]credential.PolicyDocument), nil
+ }
+}
+
+// CreatePolicy creates a new IAM policy via credential store
+func (cspm *CredentialStorePolicyManager) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
+ store := cspm.credentialManager.GetStore()
+
+ if policyStore, ok := store.(credential.PolicyManager); ok {
+ return policyStore.CreatePolicy(ctx, name, document)
+ }
+
+ return fmt.Errorf("credential store doesn't support policy creation")
+}
+
+// UpdatePolicy updates an existing IAM policy via credential store
+func (cspm *CredentialStorePolicyManager) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
+ store := cspm.credentialManager.GetStore()
+
+ if policyStore, ok := store.(credential.PolicyManager); ok {
+ return policyStore.UpdatePolicy(ctx, name, document)
+ }
+
+ return fmt.Errorf("credential store doesn't support policy updates")
+}
+
+// DeletePolicy deletes an IAM policy via credential store
+func (cspm *CredentialStorePolicyManager) DeletePolicy(ctx context.Context, name string) error {
+ store := cspm.credentialManager.GetStore()
+
+ if policyStore, ok := store.(credential.PolicyManager); ok {
+ return policyStore.DeletePolicy(ctx, name)
+ }
+
+ return fmt.Errorf("credential store doesn't support policy deletion")
+}
+
+// GetPolicy retrieves a specific IAM policy via credential store
+func (cspm *CredentialStorePolicyManager) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) {
+ store := cspm.credentialManager.GetStore()
+
+ if policyStore, ok := store.(credential.PolicyManager); ok {
+ return policyStore.GetPolicy(ctx, name)
+ }
+
+ return nil, fmt.Errorf("credential store doesn't support policy retrieval")
+}
+
+// AdminServer policy management methods using credential.PolicyManager
+func (s *AdminServer) GetPolicyManager() credential.PolicyManager {
+ if s.credentialManager == nil {
+ glog.V(1).Infof("Credential manager is nil, policy management not available")
+ return nil
+ }
+ glog.V(1).Infof("Credential manager available, creating CredentialStorePolicyManager")
+ return NewCredentialStorePolicyManager(s.credentialManager)
+}
+
+// GetPolicies retrieves all IAM policies
+func (s *AdminServer) GetPolicies() ([]IAMPolicy, error) {
+ policyManager := s.GetPolicyManager()
+ if policyManager == nil {
+ return nil, fmt.Errorf("policy manager not available")
+ }
+
+ ctx := context.Background()
+ policyMap, err := policyManager.GetPolicies(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Convert map[string]PolicyDocument to []IAMPolicy
+ var policies []IAMPolicy
+ for name, doc := range policyMap {
+ policy := IAMPolicy{
+ Name: name,
+ Document: doc,
+ DocumentJSON: "", // Will be populated if needed
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ policies = append(policies, policy)
+ }
+
+ return policies, nil
+}
+
+// CreatePolicy creates a new IAM policy
+func (s *AdminServer) CreatePolicy(name string, document credential.PolicyDocument) error {
+ policyManager := s.GetPolicyManager()
+ if policyManager == nil {
+ return fmt.Errorf("policy manager not available")
+ }
+
+ ctx := context.Background()
+ return policyManager.CreatePolicy(ctx, name, document)
+}
+
+// UpdatePolicy updates an existing IAM policy
+func (s *AdminServer) UpdatePolicy(name string, document credential.PolicyDocument) error {
+ policyManager := s.GetPolicyManager()
+ if policyManager == nil {
+ return fmt.Errorf("policy manager not available")
+ }
+
+ ctx := context.Background()
+ return policyManager.UpdatePolicy(ctx, name, document)
+}
+
+// DeletePolicy deletes an IAM policy
+func (s *AdminServer) DeletePolicy(name string) error {
+ policyManager := s.GetPolicyManager()
+ if policyManager == nil {
+ return fmt.Errorf("policy manager not available")
+ }
+
+ ctx := context.Background()
+ return policyManager.DeletePolicy(ctx, name)
+}
+
+// GetPolicy retrieves a specific IAM policy
+func (s *AdminServer) GetPolicy(name string) (*IAMPolicy, error) {
+ policyManager := s.GetPolicyManager()
+ if policyManager == nil {
+ return nil, fmt.Errorf("policy manager not available")
+ }
+
+ ctx := context.Background()
+ policyDoc, err := policyManager.GetPolicy(ctx, name)
+ if err != nil {
+ return nil, err
+ }
+
+ if policyDoc == nil {
+ return nil, nil
+ }
+
+ // Convert PolicyDocument to IAMPolicy
+ policy := &IAMPolicy{
+ Name: name,
+ Document: *policyDoc,
+ DocumentJSON: "", // Will be populated if needed
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+
+ return policy, nil
+}
diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go
index dc7905bc1..76a123a4f 100644
--- a/weed/admin/handlers/admin_handlers.go
+++ b/weed/admin/handlers/admin_handlers.go
@@ -17,6 +17,7 @@ type AdminHandlers struct {
clusterHandlers *ClusterHandlers
fileBrowserHandlers *FileBrowserHandlers
userHandlers *UserHandlers
+ policyHandlers *PolicyHandlers
maintenanceHandlers *MaintenanceHandlers
mqHandlers *MessageQueueHandlers
}
@@ -27,6 +28,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
clusterHandlers := NewClusterHandlers(adminServer)
fileBrowserHandlers := NewFileBrowserHandlers(adminServer)
userHandlers := NewUserHandlers(adminServer)
+ policyHandlers := NewPolicyHandlers(adminServer)
maintenanceHandlers := NewMaintenanceHandlers(adminServer)
mqHandlers := NewMessageQueueHandlers(adminServer)
return &AdminHandlers{
@@ -35,6 +37,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
clusterHandlers: clusterHandlers,
fileBrowserHandlers: fileBrowserHandlers,
userHandlers: userHandlers,
+ policyHandlers: policyHandlers,
maintenanceHandlers: maintenanceHandlers,
mqHandlers: mqHandlers,
}
@@ -63,6 +66,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
protected.GET("/object-store/buckets", h.ShowS3Buckets)
protected.GET("/object-store/buckets/:bucket", h.ShowBucketDetails)
protected.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
+ protected.GET("/object-store/policies", h.policyHandlers.ShowPolicies)
// File browser routes
protected.GET("/files", h.fileBrowserHandlers.ShowFileBrowser)
@@ -121,6 +125,17 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies)
}
+ // Object Store Policy management API routes
+ objectStorePoliciesApi := api.Group("/object-store/policies")
+ {
+ objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies)
+ objectStorePoliciesApi.POST("", h.policyHandlers.CreatePolicy)
+ objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy)
+ objectStorePoliciesApi.PUT("/:name", h.policyHandlers.UpdatePolicy)
+ objectStorePoliciesApi.DELETE("/:name", h.policyHandlers.DeletePolicy)
+ objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy)
+ }
+
// File management API routes
filesApi := api.Group("/files")
{
@@ -171,6 +186,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
r.GET("/object-store/buckets", h.ShowS3Buckets)
r.GET("/object-store/buckets/:bucket", h.ShowBucketDetails)
r.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
+ r.GET("/object-store/policies", h.policyHandlers.ShowPolicies)
// File browser routes
r.GET("/files", h.fileBrowserHandlers.ShowFileBrowser)
@@ -229,6 +245,17 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies)
}
+ // Object Store Policy management API routes
+ objectStorePoliciesApi := api.Group("/object-store/policies")
+ {
+ objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies)
+ objectStorePoliciesApi.POST("", h.policyHandlers.CreatePolicy)
+ objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy)
+ objectStorePoliciesApi.PUT("/:name", h.policyHandlers.UpdatePolicy)
+ objectStorePoliciesApi.DELETE("/:name", h.policyHandlers.DeletePolicy)
+ objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy)
+ }
+
// File management API routes
filesApi := api.Group("/files")
{
diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go
index 97621192e..c8e117041 100644
--- a/weed/admin/handlers/file_browser_handlers.go
+++ b/weed/admin/handlers/file_browser_handlers.go
@@ -8,6 +8,7 @@ import (
"mime/multipart"
"net"
"net/http"
+ "os"
"path/filepath"
"strconv"
"strings"
@@ -190,7 +191,7 @@ func (h *FileBrowserHandlers) CreateFolder(c *gin.Context) {
Name: filepath.Base(fullPath),
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
- FileMode: uint32(0755 | (1 << 31)), // Directory mode
+ FileMode: uint32(0755 | os.ModeDir), // Directory mode
Uid: filer_pb.OS_UID,
Gid: filer_pb.OS_GID,
Crtime: time.Now().Unix(),
@@ -656,8 +657,9 @@ func (h *FileBrowserHandlers) GetFileProperties(c *gin.Context) {
properties["created_timestamp"] = entry.Attributes.Crtime
}
- properties["file_mode"] = fmt.Sprintf("%o", entry.Attributes.FileMode)
- properties["file_mode_formatted"] = h.formatFileMode(entry.Attributes.FileMode)
+ properties["file_mode"] = dash.FormatFileMode(entry.Attributes.FileMode)
+ properties["file_mode_formatted"] = dash.FormatFileMode(entry.Attributes.FileMode)
+ properties["file_mode_octal"] = fmt.Sprintf("%o", entry.Attributes.FileMode)
properties["uid"] = entry.Attributes.Uid
properties["gid"] = entry.Attributes.Gid
properties["ttl_seconds"] = entry.Attributes.TtlSec
@@ -725,13 +727,6 @@ func (h *FileBrowserHandlers) formatBytes(bytes int64) string {
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
-// Helper function to format file mode
-func (h *FileBrowserHandlers) formatFileMode(mode uint32) string {
- // Convert to octal and format as rwx permissions
- perm := mode & 0777
- return fmt.Sprintf("%03o", perm)
-}
-
// Helper function to determine MIME type from filename
func (h *FileBrowserHandlers) determineMimeType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
diff --git a/weed/admin/handlers/maintenance_handlers.go b/weed/admin/handlers/maintenance_handlers.go
index 954874c14..4b1f91387 100644
--- a/weed/admin/handlers/maintenance_handlers.go
+++ b/weed/admin/handlers/maintenance_handlers.go
@@ -11,9 +11,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/admin/view/components"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
- "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
- "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
- "github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
@@ -114,59 +111,60 @@ func (h *MaintenanceHandlers) ShowTaskConfig(c *gin.Context) {
return
}
- // Try to get templ UI provider first
- templUIProvider := getTemplUIProvider(taskType)
+ // Try to get templ UI provider first - temporarily disabled
+ // templUIProvider := getTemplUIProvider(taskType)
var configSections []components.ConfigSectionData
- if templUIProvider != nil {
- // Use the new templ-based UI provider
- currentConfig := templUIProvider.GetCurrentConfig()
- sections, err := templUIProvider.RenderConfigSections(currentConfig)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render configuration sections: " + err.Error()})
- return
- }
- configSections = sections
- } else {
- // Fallback to basic configuration for providers that haven't been migrated yet
- configSections = []components.ConfigSectionData{
- {
- Title: "Configuration Settings",
- Icon: "fas fa-cogs",
- Description: "Configure task detection and scheduling parameters",
- Fields: []interface{}{
- components.CheckboxFieldData{
- FormFieldData: components.FormFieldData{
- Name: "enabled",
- Label: "Enable Task",
- Description: "Whether this task type should be enabled",
- },
- Checked: true,
+ // Temporarily disabled templ UI provider
+ // if templUIProvider != nil {
+ // // Use the new templ-based UI provider
+ // currentConfig := templUIProvider.GetCurrentConfig()
+ // sections, err := templUIProvider.RenderConfigSections(currentConfig)
+ // if err != nil {
+ // c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render configuration sections: " + err.Error()})
+ // return
+ // }
+ // configSections = sections
+ // } else {
+ // Fallback to basic configuration for providers that haven't been migrated yet
+ configSections = []components.ConfigSectionData{
+ {
+ Title: "Configuration Settings",
+ Icon: "fas fa-cogs",
+ Description: "Configure task detection and scheduling parameters",
+ Fields: []interface{}{
+ components.CheckboxFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "enabled",
+ Label: "Enable Task",
+ Description: "Whether this task type should be enabled",
},
- components.NumberFieldData{
- FormFieldData: components.FormFieldData{
- Name: "max_concurrent",
- Label: "Max Concurrent Tasks",
- Description: "Maximum number of concurrent tasks",
- Required: true,
- },
- Value: 2,
- Step: "1",
- Min: floatPtr(1),
+ Checked: true,
+ },
+ components.NumberFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "max_concurrent",
+ Label: "Max Concurrent Tasks",
+ Description: "Maximum number of concurrent tasks",
+ Required: true,
},
- components.DurationFieldData{
- FormFieldData: components.FormFieldData{
- Name: "scan_interval",
- Label: "Scan Interval",
- Description: "How often to scan for tasks",
- Required: true,
- },
- Value: "30m",
+ Value: 2,
+ Step: "1",
+ Min: floatPtr(1),
+ },
+ components.DurationFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "scan_interval",
+ Label: "Scan Interval",
+ Description: "How often to scan for tasks",
+ Required: true,
},
+ Value: "30m",
},
},
- }
+ },
}
+ // } // End of disabled templ UI provider else block
// Create task configuration data using templ components
configData := &app.TaskConfigTemplData{
@@ -199,8 +197,8 @@ func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) {
return
}
- // Try to get templ UI provider first
- templUIProvider := getTemplUIProvider(taskType)
+ // Try to get templ UI provider first - temporarily disabled
+ // templUIProvider := getTemplUIProvider(taskType)
// Parse form data
err := c.Request.ParseForm()
@@ -217,52 +215,53 @@ func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) {
var config interface{}
- if templUIProvider != nil {
- // Use the new templ-based UI provider
- config, err = templUIProvider.ParseConfigForm(formData)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
- return
- }
+ // Temporarily disabled templ UI provider
+ // if templUIProvider != nil {
+ // // Use the new templ-based UI provider
+ // config, err = templUIProvider.ParseConfigForm(formData)
+ // if err != nil {
+ // c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
+ // return
+ // }
+ // // Apply configuration using templ provider
+ // err = templUIProvider.ApplyConfig(config)
+ // if err != nil {
+ // c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
+ // return
+ // }
+ // } else {
+ // Fallback to old UI provider for tasks that haven't been migrated yet
+ // Fallback to old UI provider for tasks that haven't been migrated yet
+ uiRegistry := tasks.GetGlobalUIRegistry()
+ typesRegistry := tasks.GetGlobalTypesRegistry()
- // Apply configuration using templ provider
- err = templUIProvider.ApplyConfig(config)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
- return
- }
- } else {
- // Fallback to old UI provider for tasks that haven't been migrated yet
- uiRegistry := tasks.GetGlobalUIRegistry()
- typesRegistry := tasks.GetGlobalTypesRegistry()
-
- var provider types.TaskUIProvider
- for workerTaskType := range typesRegistry.GetAllDetectors() {
- if string(workerTaskType) == string(taskType) {
- provider = uiRegistry.GetProvider(workerTaskType)
- break
- }
+ var provider types.TaskUIProvider
+ for workerTaskType := range typesRegistry.GetAllDetectors() {
+ if string(workerTaskType) == string(taskType) {
+ provider = uiRegistry.GetProvider(workerTaskType)
+ break
}
+ }
- if provider == nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "UI provider not found for task type"})
- return
- }
+ if provider == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "UI provider not found for task type"})
+ return
+ }
- // Parse configuration from form using old provider
- config, err = provider.ParseConfigForm(formData)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
- return
- }
+ // Parse configuration from form using old provider
+ config, err = provider.ParseConfigForm(formData)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
+ return
+ }
- // Apply configuration using old provider
- err = provider.ApplyConfig(config)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
- return
- }
+ // Apply configuration using old provider
+ err = provider.ApplyConfig(config)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
+ return
}
+ // } // End of disabled templ UI provider else block
// Redirect back to task configuration page
c.Redirect(http.StatusSeeOther, "/maintenance/config/"+taskTypeName)
@@ -350,39 +349,35 @@ func floatPtr(f float64) *float64 {
return &f
}
-// Global templ UI registry
-var globalTemplUIRegistry *types.UITemplRegistry
+// Global templ UI registry - temporarily disabled
+// var globalTemplUIRegistry *types.UITemplRegistry
-// initTemplUIRegistry initializes the global templ UI registry
+// initTemplUIRegistry initializes the global templ UI registry - temporarily disabled
func initTemplUIRegistry() {
- if globalTemplUIRegistry == nil {
- globalTemplUIRegistry = types.NewUITemplRegistry()
-
- // Register vacuum templ UI provider using shared instances
- vacuumDetector, vacuumScheduler := vacuum.GetSharedInstances()
- vacuum.RegisterUITempl(globalTemplUIRegistry, vacuumDetector, vacuumScheduler)
-
- // Register erasure coding templ UI provider using shared instances
- erasureCodingDetector, erasureCodingScheduler := erasure_coding.GetSharedInstances()
- erasure_coding.RegisterUITempl(globalTemplUIRegistry, erasureCodingDetector, erasureCodingScheduler)
-
- // Register balance templ UI provider using shared instances
- balanceDetector, balanceScheduler := balance.GetSharedInstances()
- balance.RegisterUITempl(globalTemplUIRegistry, balanceDetector, balanceScheduler)
- }
+ // Temporarily disabled due to missing types
+ // if globalTemplUIRegistry == nil {
+ // globalTemplUIRegistry = types.NewUITemplRegistry()
+ // // Register vacuum templ UI provider using shared instances
+ // vacuumDetector, vacuumScheduler := vacuum.GetSharedInstances()
+ // vacuum.RegisterUITempl(globalTemplUIRegistry, vacuumDetector, vacuumScheduler)
+ // // Register erasure coding templ UI provider using shared instances
+ // erasureCodingDetector, erasureCodingScheduler := erasure_coding.GetSharedInstances()
+ // erasure_coding.RegisterUITempl(globalTemplUIRegistry, erasureCodingDetector, erasureCodingScheduler)
+ // // Register balance templ UI provider using shared instances
+ // balanceDetector, balanceScheduler := balance.GetSharedInstances()
+ // balance.RegisterUITempl(globalTemplUIRegistry, balanceDetector, balanceScheduler)
+ // }
}
-// getTemplUIProvider gets the templ UI provider for a task type
-func getTemplUIProvider(taskType maintenance.MaintenanceTaskType) types.TaskUITemplProvider {
- initTemplUIRegistry()
-
+// getTemplUIProvider gets the templ UI provider for a task type - temporarily disabled
+func getTemplUIProvider(taskType maintenance.MaintenanceTaskType) interface{} {
+ // initTemplUIRegistry()
// Convert maintenance task type to worker task type
- typesRegistry := tasks.GetGlobalTypesRegistry()
- for workerTaskType := range typesRegistry.GetAllDetectors() {
- if string(workerTaskType) == string(taskType) {
- return globalTemplUIRegistry.GetProvider(workerTaskType)
- }
- }
-
+ // typesRegistry := tasks.GetGlobalTypesRegistry()
+ // for workerTaskType := range typesRegistry.GetAllDetectors() {
+ // if string(workerTaskType) == string(taskType) {
+ // return globalTemplUIRegistry.GetProvider(workerTaskType)
+ // }
+ // }
return nil
}
diff --git a/weed/admin/handlers/policy_handlers.go b/weed/admin/handlers/policy_handlers.go
new file mode 100644
index 000000000..8f5cc91b1
--- /dev/null
+++ b/weed/admin/handlers/policy_handlers.go
@@ -0,0 +1,273 @@
+package handlers
+
+import (
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/seaweedfs/seaweedfs/weed/admin/dash"
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/app"
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+)
+
+// PolicyHandlers contains all the HTTP handlers for policy management
+type PolicyHandlers struct {
+ adminServer *dash.AdminServer
+}
+
+// NewPolicyHandlers creates a new instance of PolicyHandlers
+func NewPolicyHandlers(adminServer *dash.AdminServer) *PolicyHandlers {
+ return &PolicyHandlers{
+ adminServer: adminServer,
+ }
+}
+
+// ShowPolicies renders the policies management page
+func (h *PolicyHandlers) ShowPolicies(c *gin.Context) {
+ // Get policies data from the server
+ policiesData := h.getPoliciesData(c)
+
+ // Render HTML template
+ c.Header("Content-Type", "text/html")
+ policiesComponent := app.Policies(policiesData)
+ layoutComponent := layout.Layout(c, policiesComponent)
+ err := layoutComponent.Render(c.Request.Context(), c.Writer)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
+ return
+ }
+}
+
+// GetPolicies returns the list of policies as JSON
+func (h *PolicyHandlers) GetPolicies(c *gin.Context) {
+ policies, err := h.adminServer.GetPolicies()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policies: " + err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"policies": policies})
+}
+
+// CreatePolicy handles policy creation
+func (h *PolicyHandlers) CreatePolicy(c *gin.Context) {
+ var req dash.CreatePolicyRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+ return
+ }
+
+ // Validate policy name
+ if req.Name == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"})
+ return
+ }
+
+ // Check if policy already exists
+ existingPolicy, err := h.adminServer.GetPolicy(req.Name)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()})
+ return
+ }
+ if existingPolicy != nil {
+ c.JSON(http.StatusConflict, gin.H{"error": "Policy with this name already exists"})
+ return
+ }
+
+ // Create the policy
+ err = h.adminServer.CreatePolicy(req.Name, req.Document)
+ if err != nil {
+ glog.Errorf("Failed to create policy %s: %v", req.Name, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create policy: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusCreated, gin.H{
+ "success": true,
+ "message": "Policy created successfully",
+ "policy": req.Name,
+ })
+}
+
+// GetPolicy returns a specific policy
+func (h *PolicyHandlers) GetPolicy(c *gin.Context) {
+ policyName := c.Param("name")
+ if policyName == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"})
+ return
+ }
+
+ policy, err := h.adminServer.GetPolicy(policyName)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policy: " + err.Error()})
+ return
+ }
+
+ if policy == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, policy)
+}
+
+// UpdatePolicy handles policy updates
+func (h *PolicyHandlers) UpdatePolicy(c *gin.Context) {
+ policyName := c.Param("name")
+ if policyName == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"})
+ return
+ }
+
+ var req dash.UpdatePolicyRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+ return
+ }
+
+ // Check if policy exists
+ existingPolicy, err := h.adminServer.GetPolicy(policyName)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()})
+ return
+ }
+ if existingPolicy == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"})
+ return
+ }
+
+ // Update the policy
+ err = h.adminServer.UpdatePolicy(policyName, req.Document)
+ if err != nil {
+ glog.Errorf("Failed to update policy %s: %v", policyName, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update policy: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Policy updated successfully",
+ "policy": policyName,
+ })
+}
+
+// DeletePolicy handles policy deletion
+func (h *PolicyHandlers) DeletePolicy(c *gin.Context) {
+ policyName := c.Param("name")
+ if policyName == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"})
+ return
+ }
+
+ // Check if policy exists
+ existingPolicy, err := h.adminServer.GetPolicy(policyName)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()})
+ return
+ }
+ if existingPolicy == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"})
+ return
+ }
+
+ // Delete the policy
+ err = h.adminServer.DeletePolicy(policyName)
+ if err != nil {
+ glog.Errorf("Failed to delete policy %s: %v", policyName, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete policy: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Policy deleted successfully",
+ "policy": policyName,
+ })
+}
+
+// ValidatePolicy validates a policy document without saving it
+func (h *PolicyHandlers) ValidatePolicy(c *gin.Context) {
+ var req struct {
+ Document credential.PolicyDocument `json:"document" binding:"required"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+ return
+ }
+
+ // Basic validation
+ if req.Document.Version == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Policy version is required"})
+ return
+ }
+
+ if len(req.Document.Statement) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Policy must have at least one statement"})
+ return
+ }
+
+ // Validate each statement
+ for i, statement := range req.Document.Statement {
+ if statement.Effect != "Allow" && statement.Effect != "Deny" {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": fmt.Sprintf("Statement %d: Effect must be 'Allow' or 'Deny'", i+1),
+ })
+ return
+ }
+
+ if len(statement.Action) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": fmt.Sprintf("Statement %d: Action is required", i+1),
+ })
+ return
+ }
+
+ if len(statement.Resource) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": fmt.Sprintf("Statement %d: Resource is required", i+1),
+ })
+ return
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "valid": true,
+ "message": "Policy document is valid",
+ })
+}
+
+// getPoliciesData retrieves policies data from the server
+func (h *PolicyHandlers) getPoliciesData(c *gin.Context) dash.PoliciesData {
+ username := c.GetString("username")
+ if username == "" {
+ username = "admin"
+ }
+
+ // Get policies
+ policies, err := h.adminServer.GetPolicies()
+ if err != nil {
+ glog.Errorf("Failed to get policies: %v", err)
+ // Return empty data on error
+ return dash.PoliciesData{
+ Username: username,
+ Policies: []dash.IAMPolicy{},
+ TotalPolicies: 0,
+ LastUpdated: time.Now(),
+ }
+ }
+
+ // Ensure policies is never nil
+ if policies == nil {
+ policies = []dash.IAMPolicy{}
+ }
+
+ return dash.PoliciesData{
+ Username: username,
+ Policies: policies,
+ TotalPolicies: len(policies),
+ LastUpdated: time.Now(),
+ }
+}
diff --git a/weed/admin/view/app/cluster_collections.templ b/weed/admin/view/app/cluster_collections.templ
index 2bd21a3ca..9099fe112 100644
--- a/weed/admin/view/app/cluster_collections.templ
+++ b/weed/admin/view/app/cluster_collections.templ
@@ -164,22 +164,18 @@ templ ClusterCollections(data dash.ClusterCollectionsData) {
}
</td>
<td>
- <div class="btn-group btn-group-sm">
- <button type="button" class="btn btn-outline-primary btn-sm"
- title="View Details">
- <i class="fas fa-eye"></i>
- </button>
- <button type="button" class="btn btn-outline-secondary btn-sm"
- title="Edit">
- <i class="fas fa-edit"></i>
- </button>
- <button type="button" class="btn btn-outline-danger btn-sm"
- title="Delete"
- data-collection-name={collection.Name}
- onclick="confirmDeleteCollection(this)">
- <i class="fas fa-trash"></i>
- </button>
- </div>
+ <button type="button"
+ class="btn btn-outline-primary btn-sm"
+ title="View Details"
+ data-action="view-details"
+ data-name={collection.Name}
+ data-datacenter={collection.DataCenter}
+ data-volume-count={fmt.Sprintf("%d", collection.VolumeCount)}
+ data-file-count={fmt.Sprintf("%d", collection.FileCount)}
+ data-total-size={fmt.Sprintf("%d", collection.TotalSize)}
+ data-disk-types={formatDiskTypes(collection.DiskTypes)}>
+ <i class="fas fa-eye"></i>
+ </button>
</td>
</tr>
}
@@ -209,30 +205,169 @@ templ ClusterCollections(data dash.ClusterCollectionsData) {
- <!-- Delete Confirmation Modal -->
- <div class="modal fade" id="deleteCollectionModal" tabindex="-1">
- <div class="modal-dialog">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title text-danger">
- <i class="fas fa-exclamation-triangle me-2"></i>Delete Collection
- </h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
- </div>
- <div class="modal-body">
- <p>Are you sure you want to delete the collection <strong id="deleteCollectionName"></strong>?</p>
- <div class="alert alert-warning">
- <i class="fas fa-warning me-2"></i>
- This action cannot be undone. All volumes in this collection will be affected.
- </div>
- </div>
- <div class="modal-footer">
- <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
- <button type="button" class="btn btn-danger" id="confirmDeleteCollection">Delete Collection</button>
- </div>
- </div>
- </div>
- </div>
+
+
+ <!-- JavaScript for cluster collections functionality -->
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+ // Handle collection action buttons
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const action = button.getAttribute('data-action');
+
+ switch(action) {
+ case 'view-details':
+ const collectionData = {
+ name: button.getAttribute('data-name'),
+ datacenter: button.getAttribute('data-datacenter'),
+ volumeCount: parseInt(button.getAttribute('data-volume-count')),
+ fileCount: parseInt(button.getAttribute('data-file-count')),
+ totalSize: parseInt(button.getAttribute('data-total-size')),
+ diskTypes: button.getAttribute('data-disk-types')
+ };
+ showCollectionDetails(collectionData);
+ break;
+ }
+ });
+ });
+
+ function showCollectionDetails(collection) {
+ const modalHtml = '<div class="modal fade" id="collectionDetailsModal" tabindex="-1">' +
+ '<div class="modal-dialog modal-lg">' +
+ '<div class="modal-content">' +
+ '<div class="modal-header">' +
+ '<h5 class="modal-title"><i class="fas fa-layer-group me-2"></i>Collection Details: ' + collection.name + '</h5>' +
+ '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
+ '</div>' +
+ '<div class="modal-body">' +
+ '<div class="row">' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-info-circle me-1"></i>Basic Information</h6>' +
+ '<table class="table table-sm">' +
+ '<tr><td><strong>Collection Name:</strong></td><td><code>' + collection.name + '</code></td></tr>' +
+ '<tr><td><strong>Data Center:</strong></td><td>' +
+ (collection.datacenter ? '<span class="badge bg-light text-dark">' + collection.datacenter + '</span>' : '<span class="text-muted">N/A</span>') +
+ '</td></tr>' +
+ '<tr><td><strong>Disk Types:</strong></td><td>' +
+ (collection.diskTypes ? collection.diskTypes.split(', ').map(type =>
+ '<span class="badge bg-' + getDiskTypeBadgeColor(type) + ' me-1">' + type + '</span>'
+ ).join('') : '<span class="text-muted">Unknown</span>') +
+ '</td></tr>' +
+ '</table>' +
+ '</div>' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-chart-bar me-1"></i>Storage Statistics</h6>' +
+ '<table class="table table-sm">' +
+ '<tr><td><strong>Total Volumes:</strong></td><td>' +
+ '<div class="d-flex align-items-center">' +
+ '<i class="fas fa-database me-2 text-muted"></i>' +
+ '<span>' + collection.volumeCount.toLocaleString() + '</span>' +
+ '</div>' +
+ '</td></tr>' +
+ '<tr><td><strong>Total Files:</strong></td><td>' +
+ '<div class="d-flex align-items-center">' +
+ '<i class="fas fa-file me-2 text-muted"></i>' +
+ '<span>' + collection.fileCount.toLocaleString() + '</span>' +
+ '</div>' +
+ '</td></tr>' +
+ '<tr><td><strong>Total Size:</strong></td><td>' +
+ '<div class="d-flex align-items-center">' +
+ '<i class="fas fa-hdd me-2 text-muted"></i>' +
+ '<span>' + formatBytes(collection.totalSize) + '</span>' +
+ '</div>' +
+ '</td></tr>' +
+ '</table>' +
+ '</div>' +
+ '</div>' +
+ '<div class="row mt-3">' +
+ '<div class="col-12">' +
+ '<h6 class="text-primary"><i class="fas fa-link me-1"></i>Quick Actions</h6>' +
+ '<div class="d-grid gap-2 d-md-flex">' +
+ '<a href="/cluster/volumes?collection=' + encodeURIComponent(collection.name) + '" class="btn btn-outline-primary">' +
+ '<i class="fas fa-database me-1"></i>View Volumes' +
+ '</a>' +
+ '<a href="/files?collection=' + encodeURIComponent(collection.name) + '" class="btn btn-outline-info">' +
+ '<i class="fas fa-folder me-1"></i>Browse Files' +
+ '</a>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '<div class="modal-footer">' +
+ '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>';
+
+ // Remove existing modal if present
+ const existingModal = document.getElementById('collectionDetailsModal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // Add modal to body and show
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
+ const modal = new bootstrap.Modal(document.getElementById('collectionDetailsModal'));
+ modal.show();
+
+ // Remove modal when hidden
+ document.getElementById('collectionDetailsModal').addEventListener('hidden.bs.modal', function() {
+ this.remove();
+ });
+ }
+
+ function getDiskTypeBadgeColor(diskType) {
+ switch(diskType.toLowerCase()) {
+ case 'ssd':
+ return 'primary';
+ case 'hdd':
+ case '':
+ return 'secondary';
+ default:
+ return 'info';
+ }
+ }
+
+ function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ function exportCollections() {
+ // Simple CSV export of collections list
+ const rows = Array.from(document.querySelectorAll('#collectionsTable tbody tr')).map(row => {
+ const cells = row.querySelectorAll('td');
+ if (cells.length > 1) {
+ return {
+ name: cells[0].textContent.trim(),
+ volumes: cells[1].textContent.trim(),
+ files: cells[2].textContent.trim(),
+ size: cells[3].textContent.trim(),
+ diskTypes: cells[4].textContent.trim()
+ };
+ }
+ return null;
+ }).filter(row => row !== null);
+
+ const csvContent = "data:text/csv;charset=utf-8," +
+ "Collection Name,Volumes,Files,Size,Disk Types\n" +
+ rows.map(r => '"' + r.name + '","' + r.volumes + '","' + r.files + '","' + r.size + '","' + r.diskTypes + '"').join("\n");
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", "collections.csv");
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ </script>
}
func getDiskTypeColor(diskType string) string {
diff --git a/weed/admin/view/app/cluster_collections_templ.go b/weed/admin/view/app/cluster_collections_templ.go
index 8c675695a..58384c462 100644
--- a/weed/admin/view/app/cluster_collections_templ.go
+++ b/weed/admin/view/app/cluster_collections_templ.go
@@ -231,48 +231,113 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Edit\"><i class=\"fas fa-edit\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger btn-sm\" title=\"Delete\" data-collection-name=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\" data-action=\"view-details\" data-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 178, Col: 93}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 171, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" onclick=\"confirmDeleteCollection(this)\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-datacenter=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(collection.DataCenter)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 172, Col: 90}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-volume-count=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.VolumeCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 173, Col: 112}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-file-count=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.FileCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 174, Col: 108}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" data-total-size=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.TotalSize))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 175, Col: 108}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-disk-types=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var20 string
+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(formatDiskTypes(collection.DiskTypes))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 176, Col: 106}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"><i class=\"fas fa-eye\"></i></button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</tbody></table></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"text-center py-5\"><i class=\"fas fa-layer-group fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Collections Found</h5><p class=\"text-muted\">No collections are currently configured in the cluster.</p></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"text-center py-5\"><i class=\"fas fa-layer-group fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Collections Found</h5><p class=\"text-muted\">No collections are currently configured in the cluster.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var16 string
- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
+ var templ_7745c5c3_Var21 string
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 204, Col: 81}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 200, Col: 81}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</small></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteCollectionModal\" tabindex=\"-1\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title text-danger\"><i class=\"fas fa-exclamation-triangle me-2\"></i>Delete Collection</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the collection <strong id=\"deleteCollectionName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-warning me-2\"></i> This action cannot be undone. All volumes in this collection will be affected.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" id=\"confirmDeleteCollection\">Delete Collection</button></div></div></div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</small></div></div></div><!-- JavaScript for cluster collections functionality --><script>\n document.addEventListener('DOMContentLoaded', function() {\n // Handle collection action buttons\n document.addEventListener('click', function(e) {\n const button = e.target.closest('[data-action]');\n if (!button) return;\n \n const action = button.getAttribute('data-action');\n \n switch(action) {\n case 'view-details':\n const collectionData = {\n name: button.getAttribute('data-name'),\n datacenter: button.getAttribute('data-datacenter'),\n volumeCount: parseInt(button.getAttribute('data-volume-count')),\n fileCount: parseInt(button.getAttribute('data-file-count')),\n totalSize: parseInt(button.getAttribute('data-total-size')),\n diskTypes: button.getAttribute('data-disk-types')\n };\n showCollectionDetails(collectionData);\n break;\n }\n });\n });\n \n function showCollectionDetails(collection) {\n const modalHtml = '<div class=\"modal fade\" id=\"collectionDetailsModal\" tabindex=\"-1\">' +\n '<div class=\"modal-dialog modal-lg\">' +\n '<div class=\"modal-content\">' +\n '<div class=\"modal-header\">' +\n '<h5 class=\"modal-title\"><i class=\"fas fa-layer-group me-2\"></i>Collection Details: ' + collection.name + '</h5>' +\n '<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n '</div>' +\n '<div class=\"modal-body\">' +\n '<div class=\"row\">' +\n '<div class=\"col-md-6\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-info-circle me-1\"></i>Basic Information</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr><td><strong>Collection Name:</strong></td><td><code>' + collection.name + '</code></td></tr>' +\n '<tr><td><strong>Data Center:</strong></td><td>' +\n (collection.datacenter ? '<span class=\"badge bg-light text-dark\">' + collection.datacenter + '</span>' : '<span class=\"text-muted\">N/A</span>') +\n '</td></tr>' +\n '<tr><td><strong>Disk Types:</strong></td><td>' +\n (collection.diskTypes ? collection.diskTypes.split(', ').map(type => \n '<span class=\"badge bg-' + getDiskTypeBadgeColor(type) + ' me-1\">' + type + '</span>'\n ).join('') : '<span class=\"text-muted\">Unknown</span>') +\n '</td></tr>' +\n '</table>' +\n '</div>' +\n '<div class=\"col-md-6\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-chart-bar me-1\"></i>Storage Statistics</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr><td><strong>Total Volumes:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<i class=\"fas fa-database me-2 text-muted\"></i>' +\n '<span>' + collection.volumeCount.toLocaleString() + '</span>' +\n '</div>' +\n '</td></tr>' +\n '<tr><td><strong>Total Files:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<i class=\"fas fa-file me-2 text-muted\"></i>' +\n '<span>' + collection.fileCount.toLocaleString() + '</span>' +\n '</div>' +\n '</td></tr>' +\n '<tr><td><strong>Total Size:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<i class=\"fas fa-hdd me-2 text-muted\"></i>' +\n '<span>' + formatBytes(collection.totalSize) + '</span>' +\n '</div>' +\n '</td></tr>' +\n '</table>' +\n '</div>' +\n '</div>' +\n '<div class=\"row mt-3\">' +\n '<div class=\"col-12\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-link me-1\"></i>Quick Actions</h6>' +\n '<div class=\"d-grid gap-2 d-md-flex\">' +\n '<a href=\"/cluster/volumes?collection=' + encodeURIComponent(collection.name) + '\" class=\"btn btn-outline-primary\">' +\n '<i class=\"fas fa-database me-1\"></i>View Volumes' +\n '</a>' +\n '<a href=\"/files?collection=' + encodeURIComponent(collection.name) + '\" class=\"btn btn-outline-info\">' +\n '<i class=\"fas fa-folder me-1\"></i>Browse Files' +\n '</a>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '<div class=\"modal-footer\">' +\n '<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '</div>';\n \n // Remove existing modal if present\n const existingModal = document.getElementById('collectionDetailsModal');\n if (existingModal) {\n existingModal.remove();\n }\n \n // Add modal to body and show\n document.body.insertAdjacentHTML('beforeend', modalHtml);\n const modal = new bootstrap.Modal(document.getElementById('collectionDetailsModal'));\n modal.show();\n \n // Remove modal when hidden\n document.getElementById('collectionDetailsModal').addEventListener('hidden.bs.modal', function() {\n this.remove();\n });\n }\n \n function getDiskTypeBadgeColor(diskType) {\n switch(diskType.toLowerCase()) {\n case 'ssd':\n return 'primary';\n case 'hdd':\n case '':\n return 'secondary';\n default:\n return 'info';\n }\n }\n \n function formatBytes(bytes) {\n if (bytes === 0) return '0 Bytes';\n const k = 1024;\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n }\n \n function exportCollections() {\n // Simple CSV export of collections list\n const rows = Array.from(document.querySelectorAll('#collectionsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n name: cells[0].textContent.trim(),\n volumes: cells[1].textContent.trim(),\n files: cells[2].textContent.trim(),\n size: cells[3].textContent.trim(),\n diskTypes: cells[4].textContent.trim()\n };\n }\n return null;\n }).filter(row => row !== null);\n \n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Collection Name,Volumes,Files,Size,Disk Types\\n\" +\n rows.map(r => '\"' + r.name + '\",\"' + r.volumes + '\",\"' + r.files + '\",\"' + r.size + '\",\"' + r.diskTypes + '\"').join(\"\\n\");\n \n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"collections.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/cluster_filers.templ b/weed/admin/view/app/cluster_filers.templ
index 6ed14ac6e..023fd4478 100644
--- a/weed/admin/view/app/cluster_filers.templ
+++ b/weed/admin/view/app/cluster_filers.templ
@@ -121,6 +121,62 @@ templ ClusterFilers(data dash.ClusterFilersData) {
</div>
</div>
</div>
+
+ <!-- JavaScript for cluster filers functionality -->
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+ // Handle filer action buttons
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const action = button.getAttribute('data-action');
+ const address = button.getAttribute('data-address');
+
+ if (!address) return;
+
+ switch(action) {
+ case 'open-filer':
+ openFilerBrowser(address);
+ break;
+ }
+ });
+ });
+
+ function openFilerBrowser(address) {
+ // Open file browser for specific filer
+ window.open('/files?filer=' + encodeURIComponent(address), '_blank');
+ }
+
+ function exportFilers() {
+ // Simple CSV export of filers list
+ const rows = Array.from(document.querySelectorAll('#filersTable tbody tr')).map(row => {
+ const cells = row.querySelectorAll('td');
+ if (cells.length > 1) {
+ return {
+ address: cells[0].textContent.trim(),
+ version: cells[1].textContent.trim(),
+ datacenter: cells[2].textContent.trim(),
+ rack: cells[3].textContent.trim(),
+ created: cells[4].textContent.trim()
+ };
+ }
+ return null;
+ }).filter(row => row !== null);
+
+ const csvContent = "data:text/csv;charset=utf-8," +
+ "Address,Version,Data Center,Rack,Created At\n" +
+ rows.map(r => '"' + r.address + '","' + r.version + '","' + r.datacenter + '","' + r.rack + '","' + r.created + '"').join("\n");
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", "filers.csv");
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ </script>
}
\ No newline at end of file
diff --git a/weed/admin/view/app/cluster_filers_templ.go b/weed/admin/view/app/cluster_filers_templ.go
index ecc2d873e..69c489ce4 100644
--- a/weed/admin/view/app/cluster_filers_templ.go
+++ b/weed/admin/view/app/cluster_filers_templ.go
@@ -183,7 +183,7 @@ func ClusterFilers(data dash.ClusterFilersData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></div></div><!-- JavaScript for cluster filers functionality --><script>\n\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t// Handle filer action buttons\n\t\tdocument.addEventListener('click', function(e) {\n\t\t\tconst button = e.target.closest('[data-action]');\n\t\t\tif (!button) return;\n\t\t\t\n\t\t\tconst action = button.getAttribute('data-action');\n\t\t\tconst address = button.getAttribute('data-address');\n\t\t\t\n\t\t\tif (!address) return;\n\t\t\t\n\t\t\tswitch(action) {\n\t\t\t\tcase 'open-filer':\n\t\t\t\t\topenFilerBrowser(address);\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t});\n\t});\n\t\n\tfunction openFilerBrowser(address) {\n\t\t// Open file browser for specific filer\n\t\twindow.open('/files?filer=' + encodeURIComponent(address), '_blank');\n\t}\n\t\n\tfunction exportFilers() {\n\t\t// Simple CSV export of filers list\n\t\tconst rows = Array.from(document.querySelectorAll('#filersTable tbody tr')).map(row => {\n\t\t\tconst cells = row.querySelectorAll('td');\n\t\t\tif (cells.length > 1) {\n\t\t\t\treturn {\n\t\t\t\t\taddress: cells[0].textContent.trim(),\n\t\t\t\t\tversion: cells[1].textContent.trim(),\n\t\t\t\t\tdatacenter: cells[2].textContent.trim(),\n\t\t\t\t\track: cells[3].textContent.trim(),\n\t\t\t\t\tcreated: cells[4].textContent.trim()\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t}).filter(row => row !== null);\n\t\t\n\t\tconst csvContent = \"data:text/csv;charset=utf-8,\" + \n\t\t\t\"Address,Version,Data Center,Rack,Created At\\n\" +\n\t\t\trows.map(r => '\"' + r.address + '\",\"' + r.version + '\",\"' + r.datacenter + '\",\"' + r.rack + '\",\"' + r.created + '\"').join(\"\\n\");\n\t\t\n\t\tconst encodedUri = encodeURI(csvContent);\n\t\tconst link = document.createElement(\"a\");\n\t\tlink.setAttribute(\"href\", encodedUri);\n\t\tlink.setAttribute(\"download\", \"filers.csv\");\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/cluster_masters.templ b/weed/admin/view/app/cluster_masters.templ
index 9f6e2d0a9..6a53c5493 100644
--- a/weed/admin/view/app/cluster_masters.templ
+++ b/weed/admin/view/app/cluster_masters.templ
@@ -136,14 +136,15 @@ templ ClusterMasters(data dash.ClusterMastersData) {
}
</td>
<td>
- <div class="btn-group btn-group-sm">
- <button type="button" class="btn btn-outline-primary btn-sm" title="View Details">
- <i class="fas fa-eye"></i>
- </button>
- <button type="button" class="btn btn-outline-secondary btn-sm" title="Manage">
- <i class="fas fa-cog"></i>
- </button>
- </div>
+ <button type="button"
+ class="btn btn-outline-primary btn-sm"
+ title="View Details"
+ data-action="view-details"
+ data-address={master.Address}
+ data-leader={fmt.Sprintf("%t", master.IsLeader)}
+ data-suffrage={master.Suffrage}>
+ <i class="fas fa-eye"></i>
+ </button>
</td>
</tr>
}
@@ -170,6 +171,112 @@ templ ClusterMasters(data dash.ClusterMastersData) {
</div>
</div>
</div>
+
+ <!-- JavaScript for cluster masters functionality -->
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+ // Handle master action buttons
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const action = button.getAttribute('data-action');
+ const address = button.getAttribute('data-address');
+
+ if (!address) return;
+
+ switch(action) {
+ case 'view-details':
+ const isLeader = button.getAttribute('data-leader') === 'true';
+ const suffrage = button.getAttribute('data-suffrage');
+ showMasterDetails(address, isLeader, suffrage);
+ break;
+ }
+ });
+ });
+
+ function showMasterDetails(address, isLeader, suffrage) {
+ const modalHtml = '<div class="modal fade" id="masterDetailsModal" tabindex="-1">' +
+ '<div class="modal-dialog modal-lg">' +
+ '<div class="modal-content">' +
+ '<div class="modal-header">' +
+ '<h5 class="modal-title"><i class="fas fa-crown me-2"></i>Master Details: ' + address + '</h5>' +
+ '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
+ '</div>' +
+ '<div class="modal-body">' +
+ '<div class="row">' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-info-circle me-1"></i>Basic Information</h6>' +
+ '<table class="table table-sm">' +
+ '<tr><td><strong>Address:</strong></td><td>' + address + '</td></tr>' +
+ '<tr><td><strong>Role:</strong></td><td>' +
+ (isLeader ? '<span class="badge bg-warning text-dark"><i class="fas fa-star me-1"></i>Leader</span>' :
+ '<span class="badge bg-secondary">Follower</span>') + '</td></tr>' +
+ '<tr><td><strong>Suffrage:</strong></td><td>' + (suffrage || 'N/A') + '</td></tr>' +
+ '<tr><td><strong>Status:</strong></td><td><span class="badge bg-success">Active</span></td></tr>' +
+ '</table>' +
+ '</div>' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-link me-1"></i>Quick Actions</h6>' +
+ '<div class="d-grid gap-2">' +
+ '<a href="http://' + address + '" target="_blank" class="btn btn-outline-primary">' +
+ '<i class="fas fa-external-link-alt me-1"></i>Open Master UI' +
+ '</a>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '<div class="modal-footer">' +
+ '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>';
+
+ // Remove existing modal if present
+ const existingModal = document.getElementById('masterDetailsModal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // Add modal to body and show
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
+ const modal = new bootstrap.Modal(document.getElementById('masterDetailsModal'));
+ modal.show();
+
+ // Remove modal when hidden
+ document.getElementById('masterDetailsModal').addEventListener('hidden.bs.modal', function() {
+ this.remove();
+ });
+ }
+
+ function exportMasters() {
+ // Simple CSV export of masters list
+ const rows = Array.from(document.querySelectorAll('#mastersTable tbody tr')).map(row => {
+ const cells = row.querySelectorAll('td');
+ if (cells.length > 1) {
+ return {
+ address: cells[0].textContent.trim(),
+ role: cells[1].textContent.trim(),
+ suffrage: cells[2].textContent.trim()
+ };
+ }
+ return null;
+ }).filter(row => row !== null);
+
+ const csvContent = "data:text/csv;charset=utf-8," +
+ "Address,Role,Suffrage\n" +
+ rows.map(r => '"' + r.address + '","' + r.role + '","' + r.suffrage + '"').join("\n");
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", "masters.csv");
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ </script>
}
\ No newline at end of file
diff --git a/weed/admin/view/app/cluster_masters_templ.go b/weed/admin/view/app/cluster_masters_templ.go
index 951db551e..e0be75cc4 100644
--- a/weed/admin/view/app/cluster_masters_templ.go
+++ b/weed/admin/view/app/cluster_masters_templ.go
@@ -154,35 +154,74 @@ func ClusterMasters(data dash.ClusterMastersData) templ.Component {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Manage\"><i class=\"fas fa-cog\"></i></button></div></td></tr>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\" data-action=\"view-details\" data-address=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(master.Address)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 143, Col: 41}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-leader=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", master.IsLeader))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 144, Col: 60}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-suffrage=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(master.Suffrage)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 145, Col: 43}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><i class=\"fas fa-eye\"></i></button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</tbody></table></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"text-center py-5\"><i class=\"fas fa-crown fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Masters Found</h5><p class=\"text-muted\">No master servers are currently available in the cluster.</p></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"text-center py-5\"><i class=\"fas fa-crown fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Masters Found</h5><p class=\"text-muted\">No master servers are currently available in the cluster.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var7 string
- templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 168, Col: 67}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_masters.templ`, Line: 169, Col: 67}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</small></div></div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</small></div></div></div><!-- JavaScript for cluster masters functionality --><script>\n\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t// Handle master action buttons\n\t\tdocument.addEventListener('click', function(e) {\n\t\t\tconst button = e.target.closest('[data-action]');\n\t\t\tif (!button) return;\n\t\t\t\n\t\t\tconst action = button.getAttribute('data-action');\n\t\t\tconst address = button.getAttribute('data-address');\n\t\t\t\n\t\t\tif (!address) return;\n\t\t\t\n\t\t\tswitch(action) {\n\t\t\t\tcase 'view-details':\n\t\t\t\t\tconst isLeader = button.getAttribute('data-leader') === 'true';\n\t\t\t\t\tconst suffrage = button.getAttribute('data-suffrage');\n\t\t\t\t\tshowMasterDetails(address, isLeader, suffrage);\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t});\n\t});\n\t\n\tfunction showMasterDetails(address, isLeader, suffrage) {\n\t\tconst modalHtml = '<div class=\"modal fade\" id=\"masterDetailsModal\" tabindex=\"-1\">' +\n\t\t\t'<div class=\"modal-dialog modal-lg\">' +\n\t\t\t'<div class=\"modal-content\">' +\n\t\t\t'<div class=\"modal-header\">' +\n\t\t\t'<h5 class=\"modal-title\"><i class=\"fas fa-crown me-2\"></i>Master Details: ' + address + '</h5>' +\n\t\t\t'<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-body\">' +\n\t\t\t'<div class=\"row\">' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-info-circle me-1\"></i>Basic Information</h6>' +\n\t\t\t'<table class=\"table table-sm\">' +\n\t\t\t'<tr><td><strong>Address:</strong></td><td>' + address + '</td></tr>' +\n\t\t\t'<tr><td><strong>Role:</strong></td><td>' + \n\t\t\t(isLeader ? '<span class=\"badge bg-warning text-dark\"><i class=\"fas fa-star me-1\"></i>Leader</span>' : \n\t\t\t'<span class=\"badge bg-secondary\">Follower</span>') + '</td></tr>' +\n\t\t\t'<tr><td><strong>Suffrage:</strong></td><td>' + (suffrage || 'N/A') + '</td></tr>' +\n\t\t\t'<tr><td><strong>Status:</strong></td><td><span class=\"badge bg-success\">Active</span></td></tr>' +\n\t\t\t'</table>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-link me-1\"></i>Quick Actions</h6>' +\n\t\t\t'<div class=\"d-grid gap-2\">' +\n\t\t\t'<a href=\"http://' + address + '\" target=\"_blank\" class=\"btn btn-outline-primary\">' +\n\t\t\t'<i class=\"fas fa-external-link-alt me-1\"></i>Open Master UI' +\n\t\t\t'</a>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-footer\">' +\n\t\t\t'<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Remove existing modal if present\n\t\tconst existingModal = document.getElementById('masterDetailsModal');\n\t\tif (existingModal) {\n\t\t\texistingModal.remove();\n\t\t}\n\t\t\n\t\t// Add modal to body and show\n\t\tdocument.body.insertAdjacentHTML('beforeend', modalHtml);\n\t\tconst modal = new bootstrap.Modal(document.getElementById('masterDetailsModal'));\n\t\tmodal.show();\n\t\t\n\t\t// Remove modal when hidden\n\t\tdocument.getElementById('masterDetailsModal').addEventListener('hidden.bs.modal', function() {\n\t\t\tthis.remove();\n\t\t});\n\t}\n\t\n\tfunction exportMasters() {\n\t\t// Simple CSV export of masters list\n\t\tconst rows = Array.from(document.querySelectorAll('#mastersTable tbody tr')).map(row => {\n\t\t\tconst cells = row.querySelectorAll('td');\n\t\t\tif (cells.length > 1) {\n\t\t\t\treturn {\n\t\t\t\t\taddress: cells[0].textContent.trim(),\n\t\t\t\t\trole: cells[1].textContent.trim(),\n\t\t\t\t\tsuffrage: cells[2].textContent.trim()\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t}).filter(row => row !== null);\n\t\t\n\t\tconst csvContent = \"data:text/csv;charset=utf-8,\" + \n\t\t\t\"Address,Role,Suffrage\\n\" +\n\t\t\trows.map(r => '\"' + r.address + '\",\"' + r.role + '\",\"' + r.suffrage + '\"').join(\"\\n\");\n\t\t\n\t\tconst encodedUri = encodeURI(csvContent);\n\t\tconst link = document.createElement(\"a\");\n\t\tlink.setAttribute(\"href\", encodedUri);\n\t\tlink.setAttribute(\"download\", \"masters.csv\");\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/cluster_volume_servers.templ b/weed/admin/view/app/cluster_volume_servers.templ
index 20c661d40..f6b737a57 100644
--- a/weed/admin/view/app/cluster_volume_servers.templ
+++ b/weed/admin/view/app/cluster_volume_servers.templ
@@ -148,16 +148,22 @@ templ ClusterVolumeServers(data dash.ClusterVolumeServersData) {
</div>
</td>
<td>
- <div class="btn-group btn-group-sm">
- <button type="button" class="btn btn-outline-primary btn-sm"
- title="View Details">
- <i class="fas fa-eye"></i>
- </button>
- <button type="button" class="btn btn-outline-secondary btn-sm"
- title="Manage">
- <i class="fas fa-cog"></i>
- </button>
- </div>
+ <button type="button"
+ class="btn btn-outline-primary btn-sm"
+ title="View Details"
+ data-action="view-details"
+ data-id={host.ID}
+ data-address={host.Address}
+ data-public-url={host.PublicURL}
+ data-datacenter={host.DataCenter}
+ data-rack={host.Rack}
+ data-volumes={fmt.Sprintf("%d", host.Volumes)}
+ data-max-volumes={fmt.Sprintf("%d", host.MaxVolumes)}
+ data-disk-usage={fmt.Sprintf("%d", host.DiskUsage)}
+ data-disk-capacity={fmt.Sprintf("%d", host.DiskCapacity)}
+ data-last-heartbeat={host.LastHeartbeat.Format("2006-01-02 15:04:05")}>
+ <i class="fas fa-eye"></i>
+ </button>
</td>
</tr>
}
@@ -184,6 +190,161 @@ templ ClusterVolumeServers(data dash.ClusterVolumeServersData) {
</div>
</div>
</div>
+
+ <!-- JavaScript for cluster volume servers functionality -->
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+ // Handle volume server action buttons
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const action = button.getAttribute('data-action');
+
+ switch(action) {
+ case 'view-details':
+ const serverData = {
+ id: button.getAttribute('data-id'),
+ address: button.getAttribute('data-address'),
+ publicUrl: button.getAttribute('data-public-url'),
+ datacenter: button.getAttribute('data-datacenter'),
+ rack: button.getAttribute('data-rack'),
+ volumes: parseInt(button.getAttribute('data-volumes')),
+ maxVolumes: parseInt(button.getAttribute('data-max-volumes')),
+ diskUsage: parseInt(button.getAttribute('data-disk-usage')),
+ diskCapacity: parseInt(button.getAttribute('data-disk-capacity')),
+ lastHeartbeat: button.getAttribute('data-last-heartbeat')
+ };
+ showVolumeServerDetails(serverData);
+ break;
+ }
+ });
+ });
+
+ function showVolumeServerDetails(server) {
+ const volumePercent = server.maxVolumes > 0 ? Math.round((server.volumes / server.maxVolumes) * 100) : 0;
+ const diskPercent = server.diskCapacity > 0 ? Math.round((server.diskUsage / server.diskCapacity) * 100) : 0;
+
+ const modalHtml = '<div class="modal fade" id="volumeServerDetailsModal" tabindex="-1">' +
+ '<div class="modal-dialog modal-lg">' +
+ '<div class="modal-content">' +
+ '<div class="modal-header">' +
+ '<h5 class="modal-title"><i class="fas fa-server me-2"></i>Volume Server Details: ' + server.address + '</h5>' +
+ '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
+ '</div>' +
+ '<div class="modal-body">' +
+ '<div class="row">' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-info-circle me-1"></i>Basic Information</h6>' +
+ '<table class="table table-sm">' +
+ '<tr><td><strong>Server ID:</strong></td><td><code>' + server.id + '</code></td></tr>' +
+ '<tr><td><strong>Address:</strong></td><td>' + server.address + '</td></tr>' +
+ '<tr><td><strong>Public URL:</strong></td><td>' + server.publicUrl + '</td></tr>' +
+ '<tr><td><strong>Data Center:</strong></td><td><span class="badge bg-light text-dark">' + server.datacenter + '</span></td></tr>' +
+ '<tr><td><strong>Rack:</strong></td><td><span class="badge bg-light text-dark">' + server.rack + '</span></td></tr>' +
+ '<tr><td><strong>Last Heartbeat:</strong></td><td>' + server.lastHeartbeat + '</td></tr>' +
+ '</table>' +
+ '</div>' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-chart-bar me-1"></i>Usage Statistics</h6>' +
+ '<table class="table table-sm">' +
+ '<tr><td><strong>Volumes:</strong></td><td>' +
+ '<div class="d-flex align-items-center">' +
+ '<div class="progress me-2" style="width: 100px; height: 20px;">' +
+ '<div class="progress-bar" role="progressbar" style="width: ' + volumePercent + '%"></div>' +
+ '</div>' +
+ '<span>' + server.volumes + '/' + server.maxVolumes + ' (' + volumePercent + '%)</span>' +
+ '</div>' +
+ '</td></tr>' +
+ '<tr><td><strong>Disk Usage:</strong></td><td>' +
+ '<div class="d-flex align-items-center">' +
+ '<div class="progress me-2" style="width: 100px; height: 20px;">' +
+ '<div class="progress-bar" role="progressbar" style="width: ' + diskPercent + '%"></div>' +
+ '</div>' +
+ '<span>' + formatBytes(server.diskUsage) + '/' + formatBytes(server.diskCapacity) + ' (' + diskPercent + '%)</span>' +
+ '</div>' +
+ '</td></tr>' +
+ '<tr><td><strong>Available Space:</strong></td><td>' + formatBytes(server.diskCapacity - server.diskUsage) + '</td></tr>' +
+ '</table>' +
+ '</div>' +
+ '</div>' +
+ '<div class="row mt-3">' +
+ '<div class="col-12">' +
+ '<h6 class="text-primary"><i class="fas fa-link me-1"></i>Quick Actions</h6>' +
+ '<div class="d-grid gap-2 d-md-flex">' +
+ '<a href="http://' + server.publicUrl + '/ui/index.html" target="_blank" class="btn btn-outline-primary">' +
+ '<i class="fas fa-external-link-alt me-1"></i>Open Volume Server UI' +
+ '</a>' +
+ '<a href="/cluster/volumes?server=' + encodeURIComponent(server.address) + '" class="btn btn-outline-info">' +
+ '<i class="fas fa-database me-1"></i>View Volumes' +
+ '</a>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '<div class="modal-footer">' +
+ '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>';
+
+ // Remove existing modal if present
+ const existingModal = document.getElementById('volumeServerDetailsModal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // Add modal to body and show
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
+ const modal = new bootstrap.Modal(document.getElementById('volumeServerDetailsModal'));
+ modal.show();
+
+ // Remove modal when hidden
+ document.getElementById('volumeServerDetailsModal').addEventListener('hidden.bs.modal', function() {
+ this.remove();
+ });
+ }
+
+ function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ function exportVolumeServers() {
+ // Simple CSV export of volume servers list
+ const rows = Array.from(document.querySelectorAll('#hostsTable tbody tr')).map(row => {
+ const cells = row.querySelectorAll('td');
+ if (cells.length > 1) {
+ return {
+ id: cells[0].textContent.trim(),
+ address: cells[1].textContent.trim(),
+ datacenter: cells[2].textContent.trim(),
+ rack: cells[3].textContent.trim(),
+ volumes: cells[4].textContent.trim(),
+ capacity: cells[5].textContent.trim(),
+ usage: cells[6].textContent.trim()
+ };
+ }
+ return null;
+ }).filter(row => row !== null);
+
+ const csvContent = "data:text/csv;charset=utf-8," +
+ "Server ID,Address,Data Center,Rack,Volumes,Capacity,Usage\n" +
+ rows.map(r => '"' + r.id + '","' + r.address + '","' + r.datacenter + '","' + r.rack + '","' + r.volumes + '","' + r.capacity + '","' + r.usage + '"').join("\n");
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", "volume_servers.csv");
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ </script>
}
\ No newline at end of file
diff --git a/weed/admin/view/app/cluster_volume_servers_templ.go b/weed/admin/view/app/cluster_volume_servers_templ.go
index 1bd439974..094774c7a 100644
--- a/weed/admin/view/app/cluster_volume_servers_templ.go
+++ b/weed/admin/view/app/cluster_volume_servers_templ.go
@@ -213,35 +213,165 @@ func ClusterVolumeServers(data dash.ClusterVolumeServersData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></td><td><div class=\"btn-group btn-group-sm\"><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Manage\"><i class=\"fas fa-cog\"></i></button></div></td></tr>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></div></td><td><button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\" data-action=\"view-details\" data-id=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(host.ID)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 155, Col: 68}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" data-address=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(host.Address)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 156, Col: 78}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-public-url=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(host.PublicURL)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 157, Col: 83}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-datacenter=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(host.DataCenter)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 158, Col: 84}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" data-rack=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(host.Rack)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 159, Col: 72}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-volumes=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var20 string
+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", host.Volumes))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 160, Col: 97}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-max-volumes=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var21 string
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", host.MaxVolumes))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 161, Col: 104}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-disk-usage=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var22 string
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", host.DiskUsage))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 162, Col: 102}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" data-disk-capacity=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", host.DiskCapacity))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 163, Col: 108}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-last-heartbeat=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var24 string
+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(host.LastHeartbeat.Format("2006-01-02 15:04:05"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 164, Col: 121}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"><i class=\"fas fa-eye\"></i></button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</tbody></table></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"text-center py-5\"><i class=\"fas fa-server fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Volume Servers Found</h5><p class=\"text-muted\">No volume servers are currently available in the cluster.</p></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"text-center py-5\"><i class=\"fas fa-server fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Volume Servers Found</h5><p class=\"text-muted\">No volume servers are currently available in the cluster.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var15 string
- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
+ var templ_7745c5c3_Var25 string
+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 182, Col: 81}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volume_servers.templ`, Line: 188, Col: 81}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</small></div></div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</small></div></div></div><!-- JavaScript for cluster volume servers functionality --><script>\n document.addEventListener('DOMContentLoaded', function() {\n // Handle volume server action buttons\n document.addEventListener('click', function(e) {\n const button = e.target.closest('[data-action]');\n if (!button) return;\n \n const action = button.getAttribute('data-action');\n \n switch(action) {\n case 'view-details':\n const serverData = {\n id: button.getAttribute('data-id'),\n address: button.getAttribute('data-address'),\n publicUrl: button.getAttribute('data-public-url'),\n datacenter: button.getAttribute('data-datacenter'),\n rack: button.getAttribute('data-rack'),\n volumes: parseInt(button.getAttribute('data-volumes')),\n maxVolumes: parseInt(button.getAttribute('data-max-volumes')),\n diskUsage: parseInt(button.getAttribute('data-disk-usage')),\n diskCapacity: parseInt(button.getAttribute('data-disk-capacity')),\n lastHeartbeat: button.getAttribute('data-last-heartbeat')\n };\n showVolumeServerDetails(serverData);\n break;\n }\n });\n });\n \n function showVolumeServerDetails(server) {\n const volumePercent = server.maxVolumes > 0 ? Math.round((server.volumes / server.maxVolumes) * 100) : 0;\n const diskPercent = server.diskCapacity > 0 ? Math.round((server.diskUsage / server.diskCapacity) * 100) : 0;\n \n const modalHtml = '<div class=\"modal fade\" id=\"volumeServerDetailsModal\" tabindex=\"-1\">' +\n '<div class=\"modal-dialog modal-lg\">' +\n '<div class=\"modal-content\">' +\n '<div class=\"modal-header\">' +\n '<h5 class=\"modal-title\"><i class=\"fas fa-server me-2\"></i>Volume Server Details: ' + server.address + '</h5>' +\n '<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n '</div>' +\n '<div class=\"modal-body\">' +\n '<div class=\"row\">' +\n '<div class=\"col-md-6\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-info-circle me-1\"></i>Basic Information</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr><td><strong>Server ID:</strong></td><td><code>' + server.id + '</code></td></tr>' +\n '<tr><td><strong>Address:</strong></td><td>' + server.address + '</td></tr>' +\n '<tr><td><strong>Public URL:</strong></td><td>' + server.publicUrl + '</td></tr>' +\n '<tr><td><strong>Data Center:</strong></td><td><span class=\"badge bg-light text-dark\">' + server.datacenter + '</span></td></tr>' +\n '<tr><td><strong>Rack:</strong></td><td><span class=\"badge bg-light text-dark\">' + server.rack + '</span></td></tr>' +\n '<tr><td><strong>Last Heartbeat:</strong></td><td>' + server.lastHeartbeat + '</td></tr>' +\n '</table>' +\n '</div>' +\n '<div class=\"col-md-6\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-chart-bar me-1\"></i>Usage Statistics</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr><td><strong>Volumes:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<div class=\"progress me-2\" style=\"width: 100px; height: 20px;\">' +\n '<div class=\"progress-bar\" role=\"progressbar\" style=\"width: ' + volumePercent + '%\"></div>' +\n '</div>' +\n '<span>' + server.volumes + '/' + server.maxVolumes + ' (' + volumePercent + '%)</span>' +\n '</div>' +\n '</td></tr>' +\n '<tr><td><strong>Disk Usage:</strong></td><td>' +\n '<div class=\"d-flex align-items-center\">' +\n '<div class=\"progress me-2\" style=\"width: 100px; height: 20px;\">' +\n '<div class=\"progress-bar\" role=\"progressbar\" style=\"width: ' + diskPercent + '%\"></div>' +\n '</div>' +\n '<span>' + formatBytes(server.diskUsage) + '/' + formatBytes(server.diskCapacity) + ' (' + diskPercent + '%)</span>' +\n '</div>' +\n '</td></tr>' +\n '<tr><td><strong>Available Space:</strong></td><td>' + formatBytes(server.diskCapacity - server.diskUsage) + '</td></tr>' +\n '</table>' +\n '</div>' +\n '</div>' +\n '<div class=\"row mt-3\">' +\n '<div class=\"col-12\">' +\n '<h6 class=\"text-primary\"><i class=\"fas fa-link me-1\"></i>Quick Actions</h6>' +\n '<div class=\"d-grid gap-2 d-md-flex\">' +\n '<a href=\"http://' + server.publicUrl + '/ui/index.html\" target=\"_blank\" class=\"btn btn-outline-primary\">' +\n '<i class=\"fas fa-external-link-alt me-1\"></i>Open Volume Server UI' +\n '</a>' +\n '<a href=\"/cluster/volumes?server=' + encodeURIComponent(server.address) + '\" class=\"btn btn-outline-info\">' +\n '<i class=\"fas fa-database me-1\"></i>View Volumes' +\n '</a>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '<div class=\"modal-footer\">' +\n '<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n '</div>' +\n '</div>' +\n '</div>' +\n '</div>';\n \n // Remove existing modal if present\n const existingModal = document.getElementById('volumeServerDetailsModal');\n if (existingModal) {\n existingModal.remove();\n }\n \n // Add modal to body and show\n document.body.insertAdjacentHTML('beforeend', modalHtml);\n const modal = new bootstrap.Modal(document.getElementById('volumeServerDetailsModal'));\n modal.show();\n \n // Remove modal when hidden\n document.getElementById('volumeServerDetailsModal').addEventListener('hidden.bs.modal', function() {\n this.remove();\n });\n }\n \n function formatBytes(bytes) {\n if (bytes === 0) return '0 Bytes';\n const k = 1024;\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n }\n \n function exportVolumeServers() {\n // Simple CSV export of volume servers list\n const rows = Array.from(document.querySelectorAll('#hostsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n id: cells[0].textContent.trim(),\n address: cells[1].textContent.trim(),\n datacenter: cells[2].textContent.trim(),\n rack: cells[3].textContent.trim(),\n volumes: cells[4].textContent.trim(),\n capacity: cells[5].textContent.trim(),\n usage: cells[6].textContent.trim()\n };\n }\n return null;\n }).filter(row => row !== null);\n \n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Server ID,Address,Data Center,Rack,Volumes,Capacity,Usage\\n\" +\n rows.map(r => '\"' + r.id + '\",\"' + r.address + '\",\"' + r.datacenter + '\",\"' + r.rack + '\",\"' + r.volumes + '\",\"' + r.capacity + '\",\"' + r.usage + '\"').join(\"\\n\");\n \n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"volume_servers.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/file_browser.templ b/weed/admin/view/app/file_browser.templ
index a1e00555f..83db7df0f 100644
--- a/weed/admin/view/app/file_browser.templ
+++ b/weed/admin/view/app/file_browser.templ
@@ -228,7 +228,7 @@ templ FileBrowser(data dash.FileBrowserData) {
}
</td>
<td>
- <code class="small">{ entry.Mode }</code>
+ <code class="small permissions-display" data-mode={ entry.Mode } data-is-directory={ fmt.Sprintf("%t", entry.IsDirectory) }>{ entry.Mode }</code>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
@@ -356,6 +356,380 @@ templ FileBrowser(data dash.FileBrowserData) {
</div>
</div>
</div>
+
+ <!-- JavaScript for file browser functionality -->
+ <script>
+ document.addEventListener('DOMContentLoaded', function() {
+ // Format permissions in the main table
+ document.querySelectorAll('.permissions-display').forEach(element => {
+ const mode = element.getAttribute('data-mode');
+ const isDirectory = element.getAttribute('data-is-directory') === 'true';
+ if (mode) {
+ element.textContent = formatPermissions(mode, isDirectory);
+ }
+ });
+
+ // Handle file browser action buttons (download, view, properties, delete)
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const action = button.getAttribute('data-action');
+ const path = button.getAttribute('data-path');
+
+ if (!path) return;
+
+ switch(action) {
+ case 'download':
+ downloadFile(path);
+ break;
+ case 'view':
+ viewFile(path);
+ break;
+ case 'properties':
+ showFileProperties(path);
+ break;
+ case 'delete':
+ if (confirm('Are you sure you want to delete "' + path + '"?')) {
+ deleteFile(path);
+ }
+ break;
+ }
+ });
+
+ // Initialize file manager event handlers from admin.js
+ if (typeof setupFileManagerEventHandlers === 'function') {
+ setupFileManagerEventHandlers();
+ }
+ });
+
+ // File browser specific functions
+ function downloadFile(path) {
+ // Open download URL in new tab
+ window.open('/api/files/download?path=' + encodeURIComponent(path), '_blank');
+ }
+
+ function viewFile(path) {
+ // Open file viewer in new tab
+ window.open('/api/files/view?path=' + encodeURIComponent(path), '_blank');
+ }
+
+ function showFileProperties(path) {
+ // Fetch file properties and show in modal
+ fetch('/api/files/properties?path=' + encodeURIComponent(path))
+ .then(response => response.json())
+ .then(data => {
+ if (data.error) {
+ alert('Error loading file properties: ' + data.error);
+ } else {
+ displayFileProperties(data);
+ }
+ })
+ .catch(error => {
+ console.error('Error fetching file properties:', error);
+ alert('Error loading file properties: ' + error.message);
+ });
+ }
+
+ function displayFileProperties(data) {
+ // Create a comprehensive modal for file properties
+ const modalHtml = '<div class="modal fade" id="filePropertiesModal" tabindex="-1">' +
+ '<div class="modal-dialog modal-lg">' +
+ '<div class="modal-content">' +
+ '<div class="modal-header">' +
+ '<h5 class="modal-title"><i class="fas fa-info-circle me-2"></i>Properties: ' + (data.name || 'Unknown') + '</h5>' +
+ '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
+ '</div>' +
+ '<div class="modal-body">' +
+ createFilePropertiesContent(data) +
+ '</div>' +
+ '<div class="modal-footer">' +
+ '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</div>';
+
+ // Remove existing modal if present
+ const existingModal = document.getElementById('filePropertiesModal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // Add modal to body and show
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
+ const modal = new bootstrap.Modal(document.getElementById('filePropertiesModal'));
+ modal.show();
+
+ // Remove modal when hidden
+ document.getElementById('filePropertiesModal').addEventListener('hidden.bs.modal', function() {
+ this.remove();
+ });
+ }
+
+ function createFilePropertiesContent(data) {
+ let html = '<div class="row">' +
+ '<div class="col-12">' +
+ '<h6 class="text-primary"><i class="fas fa-file me-1"></i>Basic Information</h6>' +
+ '<table class="table table-sm">' +
+ '<tr><td style="width: 120px;"><strong>Name:</strong></td><td>' + (data.name || 'N/A') + '</td></tr>' +
+ '<tr><td><strong>Full Path:</strong></td><td><code class="text-break">' + (data.full_path || 'N/A') + '</code></td></tr>' +
+ '<tr><td><strong>Type:</strong></td><td>' + (data.is_directory ? 'Directory' : 'File') + '</td></tr>';
+
+ if (!data.is_directory) {
+ html += '<tr><td><strong>Size:</strong></td><td>' + (data.size_formatted || (data.size ? formatBytes(data.size) : 'N/A')) + '</td></tr>' +
+ '<tr><td><strong>MIME Type:</strong></td><td>' + (data.mime_type || 'N/A') + '</td></tr>';
+ }
+
+ html += '</table>' +
+ '</div>' +
+ '</div>' +
+ '<div class="row">' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-clock me-1"></i>Timestamps</h6>' +
+ '<table class="table table-sm">';
+
+ if (data.modified_time) {
+ html += '<tr><td><strong>Modified:</strong></td><td>' + data.modified_time + '</td></tr>';
+ }
+ if (data.created_time) {
+ html += '<tr><td><strong>Created:</strong></td><td>' + data.created_time + '</td></tr>';
+ }
+
+ html += '</table>' +
+ '</div>' +
+ '<div class="col-md-6">' +
+ '<h6 class="text-primary"><i class="fas fa-shield-alt me-1"></i>Permissions</h6>' +
+ '<table class="table table-sm">';
+
+ if (data.file_mode) {
+ const rwxPermissions = formatPermissions(data.file_mode, data.is_directory);
+ html += '<tr><td><strong>Permissions:</strong></td><td><code>' + rwxPermissions + '</code></td></tr>';
+ }
+ if (data.uid !== undefined) {
+ html += '<tr><td><strong>User ID:</strong></td><td>' + data.uid + '</td></tr>';
+ }
+ if (data.gid !== undefined) {
+ html += '<tr><td><strong>Group ID:</strong></td><td>' + data.gid + '</td></tr>';
+ }
+
+ html += '</table>' +
+ '</div>' +
+ '</div>';
+
+ // Add advanced info
+ html += '<div class="row">' +
+ '<div class="col-12">' +
+ '<h6 class="text-primary"><i class="fas fa-cog me-1"></i>Advanced</h6>' +
+ '<table class="table table-sm">';
+
+ if (data.chunk_count) {
+ html += '<tr><td style="width: 120px;"><strong>Chunks:</strong></td><td>' + data.chunk_count + '</td></tr>';
+ }
+ if (data.ttl_formatted) {
+ html += '<tr><td><strong>TTL:</strong></td><td>' + data.ttl_formatted + '</td></tr>';
+ }
+
+ html += '</table>' +
+ '</div>' +
+ '</div>';
+
+ // Add chunk details if available (show top 5)
+ if (data.chunks && data.chunks.length > 0) {
+ const chunksToShow = data.chunks.slice(0, 5);
+ html += '<div class="row mt-3">' +
+ '<div class="col-12">' +
+ '<h6 class="text-primary"><i class="fas fa-puzzle-piece me-1"></i>Chunk Details' +
+ (data.chunk_count > 5 ? ' (Top 5 of ' + data.chunk_count + ')' : ' (' + data.chunk_count + ')') +
+ '</h6>' +
+ '<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">' +
+ '<table class="table table-sm table-striped">' +
+ '<thead>' +
+ '<tr>' +
+ '<th>File ID</th>' +
+ '<th>Offset</th>' +
+ '<th>Size</th>' +
+ '<th>ETag</th>' +
+ '</tr>' +
+ '</thead>' +
+ '<tbody>';
+
+ chunksToShow.forEach(chunk => {
+ html += '<tr>' +
+ '<td><code class="small">' + (chunk.file_id || 'N/A') + '</code></td>' +
+ '<td>' + formatBytes(chunk.offset || 0) + '</td>' +
+ '<td>' + formatBytes(chunk.size || 0) + '</td>' +
+ '<td><code class="small">' + (chunk.e_tag || 'N/A') + '</code></td>' +
+ '</tr>';
+ });
+
+ html += '</tbody>' +
+ '</table>' +
+ '</div>' +
+ '</div>' +
+ '</div>';
+ }
+
+ // Add extended attributes if present
+ if (data.extended && Object.keys(data.extended).length > 0) {
+ html += '<div class="row">' +
+ '<div class="col-12">' +
+ '<h6 class="text-primary"><i class="fas fa-tags me-1"></i>Extended Attributes</h6>' +
+ '<table class="table table-sm">';
+
+ for (const [key, value] of Object.entries(data.extended)) {
+ html += '<tr><td><strong>' + key + ':</strong></td><td>' + value + '</td></tr>';
+ }
+
+ html += '</table>' +
+ '</div>' +
+ '</div>';
+ }
+
+ return html;
+ }
+
+ function uploadFile() {
+ const modal = new bootstrap.Modal(document.getElementById('uploadFileModal'));
+ modal.show();
+ }
+
+ function toggleSelectAll() {
+ const selectAllCheckbox = document.getElementById('selectAll');
+ const checkboxes = document.querySelectorAll('.file-checkbox');
+
+ checkboxes.forEach(checkbox => {
+ checkbox.checked = selectAllCheckbox.checked;
+ });
+
+ updateDeleteSelectedButton();
+ }
+
+ function updateDeleteSelectedButton() {
+ const checkboxes = document.querySelectorAll('.file-checkbox:checked');
+ const deleteBtn = document.getElementById('deleteSelectedBtn');
+
+ if (checkboxes.length > 0) {
+ deleteBtn.style.display = 'inline-block';
+ } else {
+ deleteBtn.style.display = 'none';
+ }
+ }
+
+ // Helper function to format bytes
+ function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ // Helper function to format permissions in rwxrwxrwx format
+ function formatPermissions(mode, isDirectory) {
+ // Check if mode is already in rwxrwxrwx format (e.g., "drwxr-xr-x" or "-rw-r--r--")
+ if (mode && (mode.startsWith('d') || mode.startsWith('-') || mode.startsWith('l')) && mode.length === 10) {
+ return mode; // Already formatted
+ }
+
+ // Convert to number - could be octal string or decimal
+ let permissions;
+ if (typeof mode === 'string') {
+ // Try parsing as octal first, then decimal
+ if (mode.startsWith('0') && mode.length <= 4) {
+ permissions = parseInt(mode, 8);
+ } else {
+ permissions = parseInt(mode, 10);
+ }
+ } else {
+ permissions = parseInt(mode, 10);
+ }
+
+ if (isNaN(permissions)) {
+ return isDirectory ? 'drwxr-xr-x' : '-rw-r--r--'; // Default fallback
+ }
+
+ // Handle Go's os.ModeDir conversion
+ // Go's os.ModeDir is 0x80000000 (2147483648), but Unix S_IFDIR is 0o40000 (16384)
+ let fileType = '-';
+
+ // Check for Go's os.ModeDir flag
+ if (permissions & 0x80000000) {
+ fileType = 'd';
+ }
+ // Check for standard Unix file type bits
+ else if ((permissions & 0xF000) === 0x4000) { // S_IFDIR (0o40000)
+ fileType = 'd';
+ } else if ((permissions & 0xF000) === 0x8000) { // S_IFREG (0o100000)
+ fileType = '-';
+ } else if ((permissions & 0xF000) === 0xA000) { // S_IFLNK (0o120000)
+ fileType = 'l';
+ } else if ((permissions & 0xF000) === 0x2000) { // S_IFCHR (0o020000)
+ fileType = 'c';
+ } else if ((permissions & 0xF000) === 0x6000) { // S_IFBLK (0o060000)
+ fileType = 'b';
+ } else if ((permissions & 0xF000) === 0x1000) { // S_IFIFO (0o010000)
+ fileType = 'p';
+ } else if ((permissions & 0xF000) === 0xC000) { // S_IFSOCK (0o140000)
+ fileType = 's';
+ }
+ // Fallback to isDirectory parameter if file type detection fails
+ else if (isDirectory) {
+ fileType = 'd';
+ }
+
+ // Permission bits (always use the lower 12 bits for permissions)
+ const owner = (permissions >> 6) & 7;
+ const group = (permissions >> 3) & 7;
+ const others = permissions & 7;
+
+ // Convert number to rwx format
+ function numToRwx(num) {
+ const r = (num & 4) ? 'r' : '-';
+ const w = (num & 2) ? 'w' : '-';
+ const x = (num & 1) ? 'x' : '-';
+ return r + w + x;
+ }
+
+ return fileType + numToRwx(owner) + numToRwx(group) + numToRwx(others);
+ }
+
+ function exportFileList() {
+ // Simple CSV export of file list
+ const rows = Array.from(document.querySelectorAll('#fileTable tbody tr')).map(row => {
+ const cells = row.querySelectorAll('td');
+ if (cells.length > 1) {
+ return {
+ name: cells[1].textContent.trim(),
+ size: cells[2].textContent.trim(),
+ type: cells[3].textContent.trim(),
+ modified: cells[4].textContent.trim(),
+ permissions: cells[5].textContent.trim()
+ };
+ }
+ return null;
+ }).filter(row => row !== null);
+
+ const csvContent = "data:text/csv;charset=utf-8," +
+ "Name,Size,Type,Modified,Permissions\n" +
+ rows.map(r => '"' + r.name + '","' + r.size + '","' + r.type + '","' + r.modified + '","' + r.permissions + '"').join("\n");
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", "files.csv");
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+
+ // Handle file checkbox changes
+ document.addEventListener('change', function(e) {
+ if (e.target.classList.contains('file-checkbox')) {
+ updateDeleteSelectedButton();
+ }
+ });
+ </script>
}
func countDirectories(entries []dash.FileEntry) int {
diff --git a/weed/admin/view/app/file_browser_templ.go b/weed/admin/view/app/file_browser_templ.go
index c4367e82d..ca1db51b2 100644
--- a/weed/admin/view/app/file_browser_templ.go
+++ b/weed/admin/view/app/file_browser_templ.go
@@ -392,136 +392,162 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</td><td><code class=\"small\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</td><td><code class=\"small permissions-display\" data-mode=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Mode)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 42}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</code></td><td><div class=\"btn-group btn-group-sm\" role=\"group\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" data-is-directory=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var22 string
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", entry.IsDirectory))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 131}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Mode)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 146}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</code></td><td><div class=\"btn-group btn-group-sm\" role=\"group\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !entry.IsDirectory {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"Download\" data-action=\"download\" data-path=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<button type=\"button\" class=\"btn btn-outline-primary btn-sm\" title=\"Download\" data-action=\"download\" data-path=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var22 string
- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
+ var templ_7745c5c3_Var24 string
+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 236, Col: 139}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\"><i class=\"fas fa-download\"></i></button> <button type=\"button\" class=\"btn btn-outline-info btn-sm\" title=\"View\" data-action=\"view\" data-path=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\"><i class=\"fas fa-download\"></i></button> <button type=\"button\" class=\"btn btn-outline-info btn-sm\" title=\"View\" data-action=\"view\" data-path=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var23 string
- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
+ var templ_7745c5c3_Var25 string
+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 239, Col: 128}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"><i class=\"fas fa-eye\"></i></button> ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><i class=\"fas fa-eye\"></i></button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Properties\" data-action=\"properties\" data-path=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" title=\"Properties\" data-action=\"properties\" data-path=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var24 string
- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
+ var templ_7745c5c3_Var26 string
+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 243, Col: 144}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><i class=\"fas fa-info-circle\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger btn-sm\" title=\"Delete\" data-action=\"delete\" data-path=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"><i class=\"fas fa-info-circle\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger btn-sm\" title=\"Delete\" data-action=\"delete\" data-path=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var25 string
- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
+ var templ_7745c5c3_Var27 string
+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 246, Col: 133}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</tbody></table></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"text-center py-5\"><i class=\"fas fa-folder-open fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">Empty Directory</h5><p class=\"text-muted\">This directory contains no files or subdirectories.</p></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"text-center py-5\"><i class=\"fas fa-folder-open fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">Empty Directory</h5><p class=\"text-muted\">This directory contains no files or subdirectories.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var26 string
- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
+ var templ_7745c5c3_Var28 string
+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 271, Col: 66}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</small></div></div><!-- Create Folder Modal --><div class=\"modal fade\" id=\"createFolderModal\" tabindex=\"-1\" aria-labelledby=\"createFolderModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createFolderModalLabel\"><i class=\"fas fa-folder-plus me-2\"></i>Create New Folder</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"createFolderForm\"><div class=\"mb-3\"><label for=\"folderName\" class=\"form-label\">Folder Name</label> <input type=\"text\" class=\"form-control\" id=\"folderName\" name=\"folderName\" required placeholder=\"Enter folder name\" maxlength=\"255\"><div class=\"form-text\">Folder names cannot contain / or \\ characters.</div></div><input type=\"hidden\" id=\"currentPath\" name=\"currentPath\" value=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</small></div></div><!-- Create Folder Modal --><div class=\"modal fade\" id=\"createFolderModal\" tabindex=\"-1\" aria-labelledby=\"createFolderModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createFolderModalLabel\"><i class=\"fas fa-folder-plus me-2\"></i>Create New Folder</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"createFolderForm\"><div class=\"mb-3\"><label for=\"folderName\" class=\"form-label\">Folder Name</label> <input type=\"text\" class=\"form-control\" id=\"folderName\" name=\"folderName\" required placeholder=\"Enter folder name\" maxlength=\"255\"><div class=\"form-text\">Folder names cannot contain / or \\ characters.</div></div><input type=\"hidden\" id=\"currentPath\" name=\"currentPath\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var27 string
- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
+ var templ_7745c5c3_Var29 string
+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 296, Col: 87}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\"></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitCreateFolder()\"><i class=\"fas fa-folder-plus me-1\"></i>Create Folder</button></div></div></div></div><!-- Upload File Modal --><div class=\"modal fade\" id=\"uploadFileModal\" tabindex=\"-1\" aria-labelledby=\"uploadFileModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"uploadFileModalLabel\"><i class=\"fas fa-upload me-2\"></i>Upload Files</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"uploadFileForm\" enctype=\"multipart/form-data\"><div class=\"mb-3\"><label for=\"fileInput\" class=\"form-label\">Select Files</label> <input type=\"file\" class=\"form-control\" id=\"fileInput\" name=\"files\" multiple required><div class=\"form-text\">Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.</div></div><input type=\"hidden\" id=\"uploadPath\" name=\"path\" value=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitCreateFolder()\"><i class=\"fas fa-folder-plus me-1\"></i>Create Folder</button></div></div></div></div><!-- Upload File Modal --><div class=\"modal fade\" id=\"uploadFileModal\" tabindex=\"-1\" aria-labelledby=\"uploadFileModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"uploadFileModalLabel\"><i class=\"fas fa-upload me-2\"></i>Upload Files</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"uploadFileForm\" enctype=\"multipart/form-data\"><div class=\"mb-3\"><label for=\"fileInput\" class=\"form-label\">Select Files</label> <input type=\"file\" class=\"form-control\" id=\"fileInput\" name=\"files\" multiple required><div class=\"form-text\">Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.</div></div><input type=\"hidden\" id=\"uploadPath\" name=\"path\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var28 string
- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
+ var templ_7745c5c3_Var30 string
+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 328, Col: 79}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\"><!-- File List Preview --><div id=\"fileListPreview\" class=\"mb-3\" style=\"display: none;\"><label class=\"form-label\">Selected Files:</label><div id=\"selectedFilesList\" class=\"border rounded p-2 bg-light\"><!-- Files will be listed here --></div></div><!-- Upload Progress --><div class=\"mb-3\" id=\"uploadProgress\" style=\"display: none;\"><label class=\"form-label\">Upload Progress:</label><div class=\"progress mb-2\"><div class=\"progress-bar progress-bar-striped progress-bar-animated\" role=\"progressbar\" style=\"width: 0%\" aria-valuenow=\"0\" aria-valuemin=\"0\" aria-valuemax=\"100\">0%</div></div><div id=\"uploadStatus\" class=\"small text-muted\">Preparing upload...</div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitUploadFile()\"><i class=\"fas fa-upload me-1\"></i>Upload Files</button></div></div></div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"><!-- File List Preview --><div id=\"fileListPreview\" class=\"mb-3\" style=\"display: none;\"><label class=\"form-label\">Selected Files:</label><div id=\"selectedFilesList\" class=\"border rounded p-2 bg-light\"><!-- Files will be listed here --></div></div><!-- Upload Progress --><div class=\"mb-3\" id=\"uploadProgress\" style=\"display: none;\"><label class=\"form-label\">Upload Progress:</label><div class=\"progress mb-2\"><div class=\"progress-bar progress-bar-striped progress-bar-animated\" role=\"progressbar\" style=\"width: 0%\" aria-valuenow=\"0\" aria-valuemin=\"0\" aria-valuemax=\"100\">0%</div></div><div id=\"uploadStatus\" class=\"small text-muted\">Preparing upload...</div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"submitUploadFile()\"><i class=\"fas fa-upload me-1\"></i>Upload Files</button></div></div></div></div><!-- JavaScript for file browser functionality --><script>\n\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t// Format permissions in the main table\n\t\tdocument.querySelectorAll('.permissions-display').forEach(element => {\n\t\t\tconst mode = element.getAttribute('data-mode');\n\t\t\tconst isDirectory = element.getAttribute('data-is-directory') === 'true';\n\t\t\tif (mode) {\n\t\t\t\telement.textContent = formatPermissions(mode, isDirectory);\n\t\t\t}\n\t\t});\n\t\t\n\t\t// Handle file browser action buttons (download, view, properties, delete)\n\t\tdocument.addEventListener('click', function(e) {\n\t\t\tconst button = e.target.closest('[data-action]');\n\t\t\tif (!button) return;\n\t\t\t\n\t\t\tconst action = button.getAttribute('data-action');\n\t\t\tconst path = button.getAttribute('data-path');\n\t\t\t\n\t\t\tif (!path) return;\n\t\t\t\n\t\t\tswitch(action) {\n\t\t\t\tcase 'download':\n\t\t\t\t\tdownloadFile(path);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'view':\n\t\t\t\t\tviewFile(path);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'properties':\n\t\t\t\t\tshowFileProperties(path);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'delete':\n\t\t\t\t\tif (confirm('Are you sure you want to delete \"' + path + '\"?')) {\n\t\t\t\t\t\tdeleteFile(path);\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t});\n\t\t\n\t\t// Initialize file manager event handlers from admin.js\n\t\tif (typeof setupFileManagerEventHandlers === 'function') {\n\t\t\tsetupFileManagerEventHandlers();\n\t\t}\n\t});\n\t\n\t// File browser specific functions\n\tfunction downloadFile(path) {\n\t\t// Open download URL in new tab\n\t\twindow.open('/api/files/download?path=' + encodeURIComponent(path), '_blank');\n\t}\n\t\n\tfunction viewFile(path) {\n\t\t// Open file viewer in new tab\n\t\twindow.open('/api/files/view?path=' + encodeURIComponent(path), '_blank');\n\t}\n\t\n\tfunction showFileProperties(path) {\n\t\t// Fetch file properties and show in modal\n\t\tfetch('/api/files/properties?path=' + encodeURIComponent(path))\n\t\t\t.then(response => response.json())\n\t\t\t.then(data => {\n\t\t\t\tif (data.error) {\n\t\t\t\t\talert('Error loading file properties: ' + data.error);\n\t\t\t\t} else {\n\t\t\t\t\tdisplayFileProperties(data);\n\t\t\t\t}\n\t\t\t})\n\t\t\t.catch(error => {\n\t\t\t\tconsole.error('Error fetching file properties:', error);\n\t\t\t\talert('Error loading file properties: ' + error.message);\n\t\t\t});\n\t}\n\t\n\tfunction displayFileProperties(data) {\n\t\t// Create a comprehensive modal for file properties\n\t\tconst modalHtml = '<div class=\"modal fade\" id=\"filePropertiesModal\" tabindex=\"-1\">' +\n\t\t\t'<div class=\"modal-dialog modal-lg\">' +\n\t\t\t'<div class=\"modal-content\">' +\n\t\t\t'<div class=\"modal-header\">' +\n\t\t\t'<h5 class=\"modal-title\"><i class=\"fas fa-info-circle me-2\"></i>Properties: ' + (data.name || 'Unknown') + '</h5>' +\n\t\t\t'<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-body\">' +\n\t\t\tcreateFilePropertiesContent(data) +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"modal-footer\">' +\n\t\t\t'<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Remove existing modal if present\n\t\tconst existingModal = document.getElementById('filePropertiesModal');\n\t\tif (existingModal) {\n\t\t\texistingModal.remove();\n\t\t}\n\t\t\n\t\t// Add modal to body and show\n\t\tdocument.body.insertAdjacentHTML('beforeend', modalHtml);\n\t\tconst modal = new bootstrap.Modal(document.getElementById('filePropertiesModal'));\n\t\tmodal.show();\n\t\t\n\t\t// Remove modal when hidden\n\t\tdocument.getElementById('filePropertiesModal').addEventListener('hidden.bs.modal', function() {\n\t\t\tthis.remove();\n\t\t});\n\t}\n\t\n\tfunction createFilePropertiesContent(data) {\n\t\tlet html = '<div class=\"row\">' +\n\t\t\t'<div class=\"col-12\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-file me-1\"></i>Basic Information</h6>' +\n\t\t\t'<table class=\"table table-sm\">' +\n\t\t\t'<tr><td style=\"width: 120px;\"><strong>Name:</strong></td><td>' + (data.name || 'N/A') + '</td></tr>' +\n\t\t\t'<tr><td><strong>Full Path:</strong></td><td><code class=\"text-break\">' + (data.full_path || 'N/A') + '</code></td></tr>' +\n\t\t\t'<tr><td><strong>Type:</strong></td><td>' + (data.is_directory ? 'Directory' : 'File') + '</td></tr>';\n\t\t\n\t\tif (!data.is_directory) {\n\t\t\thtml += '<tr><td><strong>Size:</strong></td><td>' + (data.size_formatted || (data.size ? formatBytes(data.size) : 'N/A')) + '</td></tr>' +\n\t\t\t\t'<tr><td><strong>MIME Type:</strong></td><td>' + (data.mime_type || 'N/A') + '</td></tr>';\n\t\t}\n\t\t\n\t\thtml += '</table>' +\n\t\t\t'</div>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"row\">' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-clock me-1\"></i>Timestamps</h6>' +\n\t\t\t'<table class=\"table table-sm\">';\n\t\t\n\t\tif (data.modified_time) {\n\t\t\thtml += '<tr><td><strong>Modified:</strong></td><td>' + data.modified_time + '</td></tr>';\n\t\t}\n\t\tif (data.created_time) {\n\t\t\thtml += '<tr><td><strong>Created:</strong></td><td>' + data.created_time + '</td></tr>';\n\t\t}\n\t\t\n\t\thtml += '</table>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"col-md-6\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-shield-alt me-1\"></i>Permissions</h6>' +\n\t\t\t'<table class=\"table table-sm\">';\n\t\t\n\t\tif (data.file_mode) {\n\t\t\tconst rwxPermissions = formatPermissions(data.file_mode, data.is_directory);\n\t\t\thtml += '<tr><td><strong>Permissions:</strong></td><td><code>' + rwxPermissions + '</code></td></tr>';\n\t\t}\n\t\tif (data.uid !== undefined) {\n\t\t\thtml += '<tr><td><strong>User ID:</strong></td><td>' + data.uid + '</td></tr>';\n\t\t}\n\t\tif (data.gid !== undefined) {\n\t\t\thtml += '<tr><td><strong>Group ID:</strong></td><td>' + data.gid + '</td></tr>';\n\t\t}\n\t\t\n\t\thtml += '</table>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Add advanced info\n\t\thtml += '<div class=\"row\">' +\n\t\t\t'<div class=\"col-12\">' +\n\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-cog me-1\"></i>Advanced</h6>' +\n\t\t\t'<table class=\"table table-sm\">';\n\t\t\n\t\tif (data.chunk_count) {\n\t\t\thtml += '<tr><td style=\"width: 120px;\"><strong>Chunks:</strong></td><td>' + data.chunk_count + '</td></tr>';\n\t\t}\n\t\tif (data.ttl_formatted) {\n\t\t\thtml += '<tr><td><strong>TTL:</strong></td><td>' + data.ttl_formatted + '</td></tr>';\n\t\t}\n\t\t\n\t\thtml += '</table>' +\n\t\t\t'</div>' +\n\t\t\t'</div>';\n\t\t\n\t\t// Add chunk details if available (show top 5)\n\t\tif (data.chunks && data.chunks.length > 0) {\n\t\t\tconst chunksToShow = data.chunks.slice(0, 5);\n\t\t\thtml += '<div class=\"row mt-3\">' +\n\t\t\t\t'<div class=\"col-12\">' +\n\t\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-puzzle-piece me-1\"></i>Chunk Details' +\n\t\t\t\t(data.chunk_count > 5 ? ' (Top 5 of ' + data.chunk_count + ')' : ' (' + data.chunk_count + ')') +\n\t\t\t\t'</h6>' +\n\t\t\t\t'<div class=\"table-responsive\" style=\"max-height: 200px; overflow-y: auto;\">' +\n\t\t\t\t'<table class=\"table table-sm table-striped\">' +\n\t\t\t\t'<thead>' +\n\t\t\t\t'<tr>' +\n\t\t\t\t'<th>File ID</th>' +\n\t\t\t\t'<th>Offset</th>' +\n\t\t\t\t'<th>Size</th>' +\n\t\t\t\t'<th>ETag</th>' +\n\t\t\t\t'</tr>' +\n\t\t\t\t'</thead>' +\n\t\t\t\t'<tbody>';\n\t\t\t\n\t\t\tchunksToShow.forEach(chunk => {\n\t\t\t\thtml += '<tr>' +\n\t\t\t\t\t'<td><code class=\"small\">' + (chunk.file_id || 'N/A') + '</code></td>' +\n\t\t\t\t\t'<td>' + formatBytes(chunk.offset || 0) + '</td>' +\n\t\t\t\t\t'<td>' + formatBytes(chunk.size || 0) + '</td>' +\n\t\t\t\t\t'<td><code class=\"small\">' + (chunk.e_tag || 'N/A') + '</code></td>' +\n\t\t\t\t\t'</tr>';\n\t\t\t});\n\t\t\t\n\t\t\thtml += '</tbody>' +\n\t\t\t\t'</table>' +\n\t\t\t\t'</div>' +\n\t\t\t\t'</div>' +\n\t\t\t\t'</div>';\n\t\t}\n\t\t\n\t\t// Add extended attributes if present\n\t\tif (data.extended && Object.keys(data.extended).length > 0) {\n\t\t\thtml += '<div class=\"row\">' +\n\t\t\t\t'<div class=\"col-12\">' +\n\t\t\t\t'<h6 class=\"text-primary\"><i class=\"fas fa-tags me-1\"></i>Extended Attributes</h6>' +\n\t\t\t\t'<table class=\"table table-sm\">';\n\t\t\t\n\t\t\tfor (const [key, value] of Object.entries(data.extended)) {\n\t\t\t\thtml += '<tr><td><strong>' + key + ':</strong></td><td>' + value + '</td></tr>';\n\t\t\t}\n\t\t\t\n\t\t\thtml += '</table>' +\n\t\t\t\t'</div>' +\n\t\t\t\t'</div>';\n\t\t}\n\t\t\n\t\treturn html;\n\t}\n\t\n\tfunction uploadFile() {\n\t\tconst modal = new bootstrap.Modal(document.getElementById('uploadFileModal'));\n\t\tmodal.show();\n\t}\n\t\n\tfunction toggleSelectAll() {\n\t\tconst selectAllCheckbox = document.getElementById('selectAll');\n\t\tconst checkboxes = document.querySelectorAll('.file-checkbox');\n\t\t\n\t\tcheckboxes.forEach(checkbox => {\n\t\t\tcheckbox.checked = selectAllCheckbox.checked;\n\t\t});\n\t\t\n\t\tupdateDeleteSelectedButton();\n\t}\n\t\n\tfunction updateDeleteSelectedButton() {\n\t\tconst checkboxes = document.querySelectorAll('.file-checkbox:checked');\n\t\tconst deleteBtn = document.getElementById('deleteSelectedBtn');\n\t\t\n\t\tif (checkboxes.length > 0) {\n\t\t\tdeleteBtn.style.display = 'inline-block';\n\t\t} else {\n\t\t\tdeleteBtn.style.display = 'none';\n\t\t}\n\t}\n\t\n\t// Helper function to format bytes\n\tfunction formatBytes(bytes) {\n\t\tif (bytes === 0) return '0 Bytes';\n\t\tconst k = 1024;\n\t\tconst sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n\t\tconst i = Math.floor(Math.log(bytes) / Math.log(k));\n\t\treturn parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n\t}\n\t\n\t// Helper function to format permissions in rwxrwxrwx format\n\tfunction formatPermissions(mode, isDirectory) {\n\t\t// Check if mode is already in rwxrwxrwx format (e.g., \"drwxr-xr-x\" or \"-rw-r--r--\")\n\t\tif (mode && (mode.startsWith('d') || mode.startsWith('-') || mode.startsWith('l')) && mode.length === 10) {\n\t\t\treturn mode; // Already formatted\n\t\t}\n\t\t\n\t\t// Convert to number - could be octal string or decimal\n\t\tlet permissions;\n\t\tif (typeof mode === 'string') {\n\t\t\t// Try parsing as octal first, then decimal\n\t\t\tif (mode.startsWith('0') && mode.length <= 4) {\n\t\t\t\tpermissions = parseInt(mode, 8);\n\t\t\t} else {\n\t\t\t\tpermissions = parseInt(mode, 10);\n\t\t\t}\n\t\t} else {\n\t\t\tpermissions = parseInt(mode, 10);\n\t\t}\n\t\t\n\t\tif (isNaN(permissions)) {\n\t\t\treturn isDirectory ? 'drwxr-xr-x' : '-rw-r--r--'; // Default fallback\n\t\t}\n\t\t\n\t\t// Handle Go's os.ModeDir conversion\n\t\t// Go's os.ModeDir is 0x80000000 (2147483648), but Unix S_IFDIR is 0o40000 (16384)\n\t\tlet fileType = '-';\n\t\t\n\t\t// Check for Go's os.ModeDir flag\n\t\tif (permissions & 0x80000000) {\n\t\t\tfileType = 'd';\n\t\t}\n\t\t// Check for standard Unix file type bits\n\t\telse if ((permissions & 0xF000) === 0x4000) { // S_IFDIR (0o40000)\n\t\t\tfileType = 'd';\n\t\t} else if ((permissions & 0xF000) === 0x8000) { // S_IFREG (0o100000)\n\t\t\tfileType = '-';\n\t\t} else if ((permissions & 0xF000) === 0xA000) { // S_IFLNK (0o120000)\n\t\t\tfileType = 'l';\n\t\t} else if ((permissions & 0xF000) === 0x2000) { // S_IFCHR (0o020000)\n\t\t\tfileType = 'c';\n\t\t} else if ((permissions & 0xF000) === 0x6000) { // S_IFBLK (0o060000)\n\t\t\tfileType = 'b';\n\t\t} else if ((permissions & 0xF000) === 0x1000) { // S_IFIFO (0o010000)\n\t\t\tfileType = 'p';\n\t\t} else if ((permissions & 0xF000) === 0xC000) { // S_IFSOCK (0o140000)\n\t\t\tfileType = 's';\n\t\t}\n\t\t// Fallback to isDirectory parameter if file type detection fails\n\t\telse if (isDirectory) {\n\t\t\tfileType = 'd';\n\t\t}\n\t\t\n\t\t// Permission bits (always use the lower 12 bits for permissions)\n\t\tconst owner = (permissions >> 6) & 7;\n\t\tconst group = (permissions >> 3) & 7;\n\t\tconst others = permissions & 7;\n\t\t\n\t\t// Convert number to rwx format\n\t\tfunction numToRwx(num) {\n\t\t\tconst r = (num & 4) ? 'r' : '-';\n\t\t\tconst w = (num & 2) ? 'w' : '-';\n\t\t\tconst x = (num & 1) ? 'x' : '-';\n\t\t\treturn r + w + x;\n\t\t}\n\t\t\n\t\treturn fileType + numToRwx(owner) + numToRwx(group) + numToRwx(others);\n\t}\n\t\n\tfunction exportFileList() {\n\t\t// Simple CSV export of file list\n\t\tconst rows = Array.from(document.querySelectorAll('#fileTable tbody tr')).map(row => {\n\t\t\tconst cells = row.querySelectorAll('td');\n\t\t\tif (cells.length > 1) {\n\t\t\t\treturn {\n\t\t\t\t\tname: cells[1].textContent.trim(),\n\t\t\t\t\tsize: cells[2].textContent.trim(),\n\t\t\t\t\ttype: cells[3].textContent.trim(),\n\t\t\t\t\tmodified: cells[4].textContent.trim(),\n\t\t\t\t\tpermissions: cells[5].textContent.trim()\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t}).filter(row => row !== null);\n\t\t\n\t\tconst csvContent = \"data:text/csv;charset=utf-8,\" + \n\t\t\t\"Name,Size,Type,Modified,Permissions\\n\" +\n\t\t\trows.map(r => '\"' + r.name + '\",\"' + r.size + '\",\"' + r.type + '\",\"' + r.modified + '\",\"' + r.permissions + '\"').join(\"\\n\");\n\t\t\n\t\tconst encodedUri = encodeURI(csvContent);\n\t\tconst link = document.createElement(\"a\");\n\t\tlink.setAttribute(\"href\", encodedUri);\n\t\tlink.setAttribute(\"download\", \"files.csv\");\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n\t\n\t// Handle file checkbox changes\n\tdocument.addEventListener('change', function(e) {\n\t\tif (e.target.classList.contains('file-checkbox')) {\n\t\t\tupdateDeleteSelectedButton();\n\t\t}\n\t});\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/object_store_users.templ b/weed/admin/view/app/object_store_users.templ
index 7c457a3d8..dedd258e2 100644
--- a/weed/admin/view/app/object_store_users.templ
+++ b/weed/admin/view/app/object_store_users.templ
@@ -317,7 +317,355 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
<!-- JavaScript for user management -->
<script>
- // User management functions will be included in admin.js
+ document.addEventListener('DOMContentLoaded', function() {
+ // Event delegation for user action buttons
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-action]');
+ if (!button) return;
+
+ const action = button.getAttribute('data-action');
+ const username = button.getAttribute('data-username');
+
+ switch (action) {
+ case 'show-user-details':
+ showUserDetails(username);
+ break;
+ case 'edit-user':
+ editUser(username);
+ break;
+ case 'manage-access-keys':
+ manageAccessKeys(username);
+ break;
+ case 'delete-user':
+ deleteUser(username);
+ break;
+ }
+ });
+ });
+
+ // Show user details modal
+ async function showUserDetails(username) {
+ try {
+ const response = await fetch(`/api/users/${username}`);
+ if (response.ok) {
+ const user = await response.json();
+ document.getElementById('userDetailsContent').innerHTML = createUserDetailsContent(user);
+ const modal = new bootstrap.Modal(document.getElementById('userDetailsModal'));
+ modal.show();
+ } else {
+ showErrorMessage('Failed to load user details');
+ }
+ } catch (error) {
+ console.error('Error loading user details:', error);
+ showErrorMessage('Failed to load user details');
+ }
+ }
+
+ // Edit user function
+ async function editUser(username) {
+ try {
+ const response = await fetch(`/api/users/${username}`);
+ if (response.ok) {
+ const user = await response.json();
+
+ // Populate edit form
+ document.getElementById('editUsername').value = username;
+ document.getElementById('editEmail').value = user.email || '';
+
+ // Set selected actions
+ const actionsSelect = document.getElementById('editActions');
+ Array.from(actionsSelect.options).forEach(option => {
+ option.selected = user.actions && user.actions.includes(option.value);
+ });
+
+ // Show modal
+ const modal = new bootstrap.Modal(document.getElementById('editUserModal'));
+ modal.show();
+ } else {
+ showErrorMessage('Failed to load user details');
+ }
+ } catch (error) {
+ console.error('Error loading user:', error);
+ showErrorMessage('Failed to load user details');
+ }
+ }
+
+ // Manage access keys function
+ async function manageAccessKeys(username) {
+ try {
+ const response = await fetch(`/api/users/${username}`);
+ if (response.ok) {
+ const user = await response.json();
+ document.getElementById('accessKeysUsername').textContent = username;
+ document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);
+ const modal = new bootstrap.Modal(document.getElementById('accessKeysModal'));
+ modal.show();
+ } else {
+ showErrorMessage('Failed to load access keys');
+ }
+ } catch (error) {
+ console.error('Error loading access keys:', error);
+ showErrorMessage('Failed to load access keys');
+ }
+ }
+
+ // Delete user function
+ async function deleteUser(username) {
+ if (confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
+ try {
+ const response = await fetch(`/api/users/${username}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ showSuccessMessage('User deleted successfully');
+ setTimeout(() => window.location.reload(), 1000);
+ } else {
+ const error = await response.json();
+ showErrorMessage('Failed to delete user: ' + (error.error || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error deleting user:', error);
+ showErrorMessage('Failed to delete user: ' + error.message);
+ }
+ }
+ }
+
+ // Handle create user form submission
+ async function handleCreateUser() {
+ const form = document.getElementById('createUserForm');
+ const formData = new FormData(form);
+
+ const userData = {
+ username: formData.get('username'),
+ email: formData.get('email'),
+ actions: Array.from(document.getElementById('actions').selectedOptions).map(option => option.value),
+ generate_key: document.getElementById('generateKey').checked
+ };
+
+ try {
+ const response = await fetch('/api/users', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(userData)
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ showSuccessMessage('User created successfully');
+
+ // Show the created access key if generated
+ if (result.user && result.user.access_key) {
+ showNewAccessKeyModal(result.user);
+ }
+
+ // Close modal and refresh page
+ const modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal'));
+ modal.hide();
+ form.reset();
+ setTimeout(() => window.location.reload(), 1000);
+ } else {
+ const error = await response.json();
+ showErrorMessage('Failed to create user: ' + (error.error || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error creating user:', error);
+ showErrorMessage('Failed to create user: ' + error.message);
+ }
+ }
+
+ // Handle update user form submission
+ async function handleUpdateUser() {
+ const username = document.getElementById('editUsername').value;
+ const formData = new FormData(document.getElementById('editUserForm'));
+
+ const userData = {
+ email: formData.get('email'),
+ actions: Array.from(document.getElementById('editActions').selectedOptions).map(option => option.value)
+ };
+
+ try {
+ const response = await fetch(`/api/users/${username}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(userData)
+ });
+
+ if (response.ok) {
+ showSuccessMessage('User updated successfully');
+
+ // Close modal and refresh page
+ const modal = bootstrap.Modal.getInstance(document.getElementById('editUserModal'));
+ modal.hide();
+ setTimeout(() => window.location.reload(), 1000);
+ } else {
+ const error = await response.json();
+ showErrorMessage('Failed to update user: ' + (error.error || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error updating user:', error);
+ showErrorMessage('Failed to update user: ' + error.message);
+ }
+ }
+
+ // Create user details content
+ function createUserDetailsContent(user) {
+ var detailsHtml = '<div class="row">';
+ detailsHtml += '<div class="col-md-6">';
+ detailsHtml += '<h6 class="text-muted">Basic Information</h6>';
+ detailsHtml += '<table class="table table-sm">';
+ detailsHtml += '<tr><td><strong>Username:</strong></td><td>' + escapeHtml(user.username) + '</td></tr>';
+ detailsHtml += '<tr><td><strong>Email:</strong></td><td>' + escapeHtml(user.email || 'Not set') + '</td></tr>';
+ detailsHtml += '</table>';
+ detailsHtml += '</div>';
+ detailsHtml += '<div class="col-md-6">';
+ detailsHtml += '<h6 class="text-muted">Permissions</h6>';
+ detailsHtml += '<div class="mb-3">';
+ if (user.actions && user.actions.length > 0) {
+ detailsHtml += user.actions.map(function(action) {
+ return '<span class="badge bg-info me-1">' + action + '</span>';
+ }).join('');
+ } else {
+ detailsHtml += '<span class="text-muted">No permissions assigned</span>';
+ }
+ detailsHtml += '</div>';
+ detailsHtml += '<h6 class="text-muted">Access Keys</h6>';
+ if (user.access_keys && user.access_keys.length > 0) {
+ detailsHtml += '<div class="mb-2">';
+ user.access_keys.forEach(function(key) {
+ detailsHtml += '<div><code class="text-muted">' + key.access_key + '</code></div>';
+ });
+ detailsHtml += '</div>';
+ } else {
+ detailsHtml += '<p class="text-muted">No access keys</p>';
+ }
+ detailsHtml += '</div>';
+ detailsHtml += '</div>';
+ return detailsHtml;
+ }
+
+ // Create access keys content
+ function createAccessKeysContent(user) {
+ if (!user.access_keys || user.access_keys.length === 0) {
+ return '<p class="text-muted">No access keys available</p>';
+ }
+
+ var keysHtml = '<div class="table-responsive">';
+ keysHtml += '<table class="table table-sm">';
+ keysHtml += '<thead><tr><th>Access Key</th><th>Status</th><th>Actions</th></tr></thead>';
+ keysHtml += '<tbody>';
+
+ user.access_keys.forEach(function(key) {
+ keysHtml += '<tr>';
+ keysHtml += '<td><code>' + key.access_key + '</code></td>';
+ keysHtml += '<td><span class="badge bg-success">Active</span></td>';
+ keysHtml += '<td>';
+ keysHtml += '<button class="btn btn-outline-danger btn-sm" onclick="deleteAccessKey(\'' + user.username + '\', \'' + key.access_key + '\')">';
+ keysHtml += '<i class="fas fa-trash"></i> Delete';
+ keysHtml += '</button>';
+ keysHtml += '</td>';
+ keysHtml += '</tr>';
+ });
+
+ keysHtml += '</tbody>';
+ keysHtml += '</table>';
+ keysHtml += '</div>';
+ return keysHtml;
+ }
+
+ // Create new access key
+ async function createAccessKey() {
+ const username = document.getElementById('accessKeysUsername').textContent;
+
+ try {
+ const response = await fetch(`/api/users/${username}/access-keys`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({})
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ showSuccessMessage('Access key created successfully');
+
+ // Refresh access keys display
+ const userResponse = await fetch(`/api/users/${username}`);
+ if (userResponse.ok) {
+ const user = await userResponse.json();
+ document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);
+ }
+ } else {
+ const error = await response.json();
+ showErrorMessage('Failed to create access key: ' + (error.error || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error creating access key:', error);
+ showErrorMessage('Failed to create access key: ' + error.message);
+ }
+ }
+
+ // Delete access key
+ async function deleteAccessKey(username, accessKey) {
+ if (confirm('Are you sure you want to delete this access key?')) {
+ try {
+ const response = await fetch(`/api/users/${username}/access-keys/${accessKey}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ showSuccessMessage('Access key deleted successfully');
+
+ // Refresh access keys display
+ const userResponse = await fetch(`/api/users/${username}`);
+ if (userResponse.ok) {
+ const user = await userResponse.json();
+ document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);
+ }
+ } else {
+ const error = await response.json();
+ showErrorMessage('Failed to delete access key: ' + (error.error || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error deleting access key:', error);
+ showErrorMessage('Failed to delete access key: ' + error.message);
+ }
+ }
+ }
+
+ // Show new access key modal (when user is created with generated key)
+ function showNewAccessKeyModal(user) {
+ // Create a simple alert for now - could be enhanced with a dedicated modal
+ var message = 'New user created!\n\n';
+ message += 'Username: ' + user.username + '\n';
+ message += 'Access Key: ' + user.access_key + '\n';
+ message += 'Secret Key: ' + user.secret_key + '\n\n';
+ message += 'Please save these credentials securely.';
+ alert(message);
+ }
+
+ // Utility functions
+ function showSuccessMessage(message) {
+ // Simple implementation - could be enhanced with toast notifications
+ alert('Success: ' + message);
+ }
+
+ function showErrorMessage(message) {
+ // Simple implementation - could be enhanced with toast notifications
+ alert('Error: ' + message);
+ }
+
+ function escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
</script>
}
diff --git a/weed/admin/view/app/object_store_users_templ.go b/weed/admin/view/app/object_store_users_templ.go
index a4a194d59..8d08d5161 100644
--- a/weed/admin/view/app/object_store_users_templ.go
+++ b/weed/admin/view/app/object_store_users_templ.go
@@ -193,7 +193,7 @@ func ObjectStoreUsers(data dash.ObjectStoreUsersData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</small></div></div></div><!-- Create User Modal --><div class=\"modal fade\" id=\"createUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-plus me-2\"></i>Create New User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"createUserForm\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username *</label> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div><div class=\"mb-3\"><label for=\"email\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"email\" name=\"email\"></div><div class=\"mb-3\"><label for=\"actions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"actions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple permissions</small></div><div class=\"mb-3 form-check\"><input type=\"checkbox\" class=\"form-check-input\" id=\"generateKey\" name=\"generateKey\" checked> <label class=\"form-check-label\" for=\"generateKey\">Generate access key automatically</label></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleCreateUser()\">Create User</button></div></div></div></div><!-- Edit User Modal --><div class=\"modal fade\" id=\"editUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-edit me-2\"></i>Edit User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"editUserForm\"><input type=\"hidden\" id=\"editUsername\" name=\"username\"><div class=\"mb-3\"><label for=\"editEmail\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"editEmail\" name=\"email\"></div><div class=\"mb-3\"><label for=\"editActions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"editActions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleUpdateUser()\">Update User</button></div></div></div></div><!-- User Details Modal --><div class=\"modal fade\" id=\"userDetailsModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user me-2\"></i>User Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\" id=\"userDetailsContent\"><!-- Content will be loaded dynamically --></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- Access Keys Management Modal --><div class=\"modal fade\" id=\"accessKeysModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-key me-2\"></i>Manage Access Keys</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><div class=\"d-flex justify-content-between align-items-center mb-3\"><h6>Access Keys for <span id=\"accessKeysUsername\"></span></h6><button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createAccessKey()\"><i class=\"fas fa-plus me-1\"></i>Create New Key</button></div><div id=\"accessKeysContent\"><!-- Content will be loaded dynamically --></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- JavaScript for user management --><script>\n // User management functions will be included in admin.js\n </script>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</small></div></div></div><!-- Create User Modal --><div class=\"modal fade\" id=\"createUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-plus me-2\"></i>Create New User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"createUserForm\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username *</label> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div><div class=\"mb-3\"><label for=\"email\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"email\" name=\"email\"></div><div class=\"mb-3\"><label for=\"actions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"actions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple permissions</small></div><div class=\"mb-3 form-check\"><input type=\"checkbox\" class=\"form-check-input\" id=\"generateKey\" name=\"generateKey\" checked> <label class=\"form-check-label\" for=\"generateKey\">Generate access key automatically</label></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleCreateUser()\">Create User</button></div></div></div></div><!-- Edit User Modal --><div class=\"modal fade\" id=\"editUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user-edit me-2\"></i>Edit User</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><form id=\"editUserForm\"><input type=\"hidden\" id=\"editUsername\" name=\"username\"><div class=\"mb-3\"><label for=\"editEmail\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"editEmail\" name=\"email\"></div><div class=\"mb-3\"><label for=\"editActions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"editActions\" name=\"actions\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option></select></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleUpdateUser()\">Update User</button></div></div></div></div><!-- User Details Modal --><div class=\"modal fade\" id=\"userDetailsModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-user me-2\"></i>User Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\" id=\"userDetailsContent\"><!-- Content will be loaded dynamically --></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- Access Keys Management Modal --><div class=\"modal fade\" id=\"accessKeysModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog modal-lg\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-key me-2\"></i>Manage Access Keys</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><div class=\"d-flex justify-content-between align-items-center mb-3\"><h6>Access Keys for <span id=\"accessKeysUsername\"></span></h6><button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createAccessKey()\"><i class=\"fas fa-plus me-1\"></i>Create New Key</button></div><div id=\"accessKeysContent\"><!-- Content will be loaded dynamically --></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- JavaScript for user management --><script>\n document.addEventListener('DOMContentLoaded', function() {\n // Event delegation for user action buttons\n document.addEventListener('click', function(e) {\n const button = e.target.closest('[data-action]');\n if (!button) return;\n \n const action = button.getAttribute('data-action');\n const username = button.getAttribute('data-username');\n \n switch (action) {\n case 'show-user-details':\n showUserDetails(username);\n break;\n case 'edit-user':\n editUser(username);\n break;\n case 'manage-access-keys':\n manageAccessKeys(username);\n break;\n case 'delete-user':\n deleteUser(username);\n break;\n }\n });\n });\n\n // Show user details modal\n async function showUserDetails(username) {\n try {\n const response = await fetch(`/api/users/${username}`);\n if (response.ok) {\n const user = await response.json();\n document.getElementById('userDetailsContent').innerHTML = createUserDetailsContent(user);\n const modal = new bootstrap.Modal(document.getElementById('userDetailsModal'));\n modal.show();\n } else {\n showErrorMessage('Failed to load user details');\n }\n } catch (error) {\n console.error('Error loading user details:', error);\n showErrorMessage('Failed to load user details');\n }\n }\n\n // Edit user function\n async function editUser(username) {\n try {\n const response = await fetch(`/api/users/${username}`);\n if (response.ok) {\n const user = await response.json();\n \n // Populate edit form\n document.getElementById('editUsername').value = username;\n document.getElementById('editEmail').value = user.email || '';\n \n // Set selected actions\n const actionsSelect = document.getElementById('editActions');\n Array.from(actionsSelect.options).forEach(option => {\n option.selected = user.actions && user.actions.includes(option.value);\n });\n \n // Show modal\n const modal = new bootstrap.Modal(document.getElementById('editUserModal'));\n modal.show();\n } else {\n showErrorMessage('Failed to load user details');\n }\n } catch (error) {\n console.error('Error loading user:', error);\n showErrorMessage('Failed to load user details');\n }\n }\n\n // Manage access keys function\n async function manageAccessKeys(username) {\n try {\n const response = await fetch(`/api/users/${username}`);\n if (response.ok) {\n const user = await response.json();\n document.getElementById('accessKeysUsername').textContent = username;\n document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);\n const modal = new bootstrap.Modal(document.getElementById('accessKeysModal'));\n modal.show();\n } else {\n showErrorMessage('Failed to load access keys');\n }\n } catch (error) {\n console.error('Error loading access keys:', error);\n showErrorMessage('Failed to load access keys');\n }\n }\n\n // Delete user function\n async function deleteUser(username) {\n if (confirm(`Are you sure you want to delete user \"${username}\"? This action cannot be undone.`)) {\n try {\n const response = await fetch(`/api/users/${username}`, {\n method: 'DELETE'\n });\n \n if (response.ok) {\n showSuccessMessage('User deleted successfully');\n setTimeout(() => window.location.reload(), 1000);\n } else {\n const error = await response.json();\n showErrorMessage('Failed to delete user: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error deleting user:', error);\n showErrorMessage('Failed to delete user: ' + error.message);\n }\n }\n }\n\n // Handle create user form submission\n async function handleCreateUser() {\n const form = document.getElementById('createUserForm');\n const formData = new FormData(form);\n \n const userData = {\n username: formData.get('username'),\n email: formData.get('email'),\n actions: Array.from(document.getElementById('actions').selectedOptions).map(option => option.value),\n generate_key: document.getElementById('generateKey').checked\n };\n \n try {\n const response = await fetch('/api/users', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(userData)\n });\n \n if (response.ok) {\n const result = await response.json();\n showSuccessMessage('User created successfully');\n \n // Show the created access key if generated\n if (result.user && result.user.access_key) {\n showNewAccessKeyModal(result.user);\n }\n \n // Close modal and refresh page\n const modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal'));\n modal.hide();\n form.reset();\n setTimeout(() => window.location.reload(), 1000);\n } else {\n const error = await response.json();\n showErrorMessage('Failed to create user: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error creating user:', error);\n showErrorMessage('Failed to create user: ' + error.message);\n }\n }\n\n // Handle update user form submission\n async function handleUpdateUser() {\n const username = document.getElementById('editUsername').value;\n const formData = new FormData(document.getElementById('editUserForm'));\n \n const userData = {\n email: formData.get('email'),\n actions: Array.from(document.getElementById('editActions').selectedOptions).map(option => option.value)\n };\n \n try {\n const response = await fetch(`/api/users/${username}`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(userData)\n });\n \n if (response.ok) {\n showSuccessMessage('User updated successfully');\n \n // Close modal and refresh page\n const modal = bootstrap.Modal.getInstance(document.getElementById('editUserModal'));\n modal.hide();\n setTimeout(() => window.location.reload(), 1000);\n } else {\n const error = await response.json();\n showErrorMessage('Failed to update user: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error updating user:', error);\n showErrorMessage('Failed to update user: ' + error.message);\n }\n }\n\n // Create user details content\n function createUserDetailsContent(user) {\n var detailsHtml = '<div class=\"row\">';\n detailsHtml += '<div class=\"col-md-6\">';\n detailsHtml += '<h6 class=\"text-muted\">Basic Information</h6>';\n detailsHtml += '<table class=\"table table-sm\">';\n detailsHtml += '<tr><td><strong>Username:</strong></td><td>' + escapeHtml(user.username) + '</td></tr>';\n detailsHtml += '<tr><td><strong>Email:</strong></td><td>' + escapeHtml(user.email || 'Not set') + '</td></tr>';\n detailsHtml += '</table>';\n detailsHtml += '</div>';\n detailsHtml += '<div class=\"col-md-6\">';\n detailsHtml += '<h6 class=\"text-muted\">Permissions</h6>';\n detailsHtml += '<div class=\"mb-3\">';\n if (user.actions && user.actions.length > 0) {\n detailsHtml += user.actions.map(function(action) {\n return '<span class=\"badge bg-info me-1\">' + action + '</span>';\n }).join('');\n } else {\n detailsHtml += '<span class=\"text-muted\">No permissions assigned</span>';\n }\n detailsHtml += '</div>';\n detailsHtml += '<h6 class=\"text-muted\">Access Keys</h6>';\n if (user.access_keys && user.access_keys.length > 0) {\n detailsHtml += '<div class=\"mb-2\">';\n user.access_keys.forEach(function(key) {\n detailsHtml += '<div><code class=\"text-muted\">' + key.access_key + '</code></div>';\n });\n detailsHtml += '</div>';\n } else {\n detailsHtml += '<p class=\"text-muted\">No access keys</p>';\n }\n detailsHtml += '</div>';\n detailsHtml += '</div>';\n return detailsHtml;\n }\n\n // Create access keys content\n function createAccessKeysContent(user) {\n if (!user.access_keys || user.access_keys.length === 0) {\n return '<p class=\"text-muted\">No access keys available</p>';\n }\n \n var keysHtml = '<div class=\"table-responsive\">';\n keysHtml += '<table class=\"table table-sm\">';\n keysHtml += '<thead><tr><th>Access Key</th><th>Status</th><th>Actions</th></tr></thead>';\n keysHtml += '<tbody>';\n \n user.access_keys.forEach(function(key) {\n keysHtml += '<tr>';\n keysHtml += '<td><code>' + key.access_key + '</code></td>';\n keysHtml += '<td><span class=\"badge bg-success\">Active</span></td>';\n keysHtml += '<td>';\n keysHtml += '<button class=\"btn btn-outline-danger btn-sm\" onclick=\"deleteAccessKey(\\'' + user.username + '\\', \\'' + key.access_key + '\\')\">';\n keysHtml += '<i class=\"fas fa-trash\"></i> Delete';\n keysHtml += '</button>';\n keysHtml += '</td>';\n keysHtml += '</tr>';\n });\n \n keysHtml += '</tbody>';\n keysHtml += '</table>';\n keysHtml += '</div>';\n return keysHtml;\n }\n\n // Create new access key\n async function createAccessKey() {\n const username = document.getElementById('accessKeysUsername').textContent;\n \n try {\n const response = await fetch(`/api/users/${username}/access-keys`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({})\n });\n \n if (response.ok) {\n const result = await response.json();\n showSuccessMessage('Access key created successfully');\n \n // Refresh access keys display\n const userResponse = await fetch(`/api/users/${username}`);\n if (userResponse.ok) {\n const user = await userResponse.json();\n document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);\n }\n } else {\n const error = await response.json();\n showErrorMessage('Failed to create access key: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error creating access key:', error);\n showErrorMessage('Failed to create access key: ' + error.message);\n }\n }\n\n // Delete access key\n async function deleteAccessKey(username, accessKey) {\n if (confirm('Are you sure you want to delete this access key?')) {\n try {\n const response = await fetch(`/api/users/${username}/access-keys/${accessKey}`, {\n method: 'DELETE'\n });\n \n if (response.ok) {\n showSuccessMessage('Access key deleted successfully');\n \n // Refresh access keys display\n const userResponse = await fetch(`/api/users/${username}`);\n if (userResponse.ok) {\n const user = await userResponse.json();\n document.getElementById('accessKeysContent').innerHTML = createAccessKeysContent(user);\n }\n } else {\n const error = await response.json();\n showErrorMessage('Failed to delete access key: ' + (error.error || 'Unknown error'));\n }\n } catch (error) {\n console.error('Error deleting access key:', error);\n showErrorMessage('Failed to delete access key: ' + error.message);\n }\n }\n }\n\n // Show new access key modal (when user is created with generated key)\n function showNewAccessKeyModal(user) {\n // Create a simple alert for now - could be enhanced with a dedicated modal\n var message = 'New user created!\\n\\n';\n message += 'Username: ' + user.username + '\\n';\n message += 'Access Key: ' + user.access_key + '\\n';\n message += 'Secret Key: ' + user.secret_key + '\\n\\n';\n message += 'Please save these credentials securely.';\n alert(message);\n }\n\n // Utility functions\n function showSuccessMessage(message) {\n // Simple implementation - could be enhanced with toast notifications\n alert('Success: ' + message);\n }\n\n function showErrorMessage(message) {\n // Simple implementation - could be enhanced with toast notifications\n alert('Error: ' + message);\n }\n\n function escapeHtml(text) {\n if (!text) return '';\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/policies.templ b/weed/admin/view/app/policies.templ
new file mode 100644
index 000000000..e613d535e
--- /dev/null
+++ b/weed/admin/view/app/policies.templ
@@ -0,0 +1,658 @@
+package app
+
+import (
+ "fmt"
+ "github.com/seaweedfs/seaweedfs/weed/admin/dash"
+)
+
+templ Policies(data dash.PoliciesData) {
+ <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
+ <h1 class="h2">
+ <i class="fas fa-shield-alt me-2"></i>IAM Policies
+ </h1>
+ <div class="btn-toolbar mb-2 mb-md-0">
+ <div class="btn-group me-2">
+ <button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createPolicyModal">
+ <i class="fas fa-plus me-1"></i>Create Policy
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div id="policies-content">
+ <!-- Summary Cards -->
+ <div class="row mb-4">
+ <div class="col-xl-4 col-md-6 mb-4">
+ <div class="card border-left-primary shadow h-100 py-2">
+ <div class="card-body">
+ <div class="row no-gutters align-items-center">
+ <div class="col mr-2">
+ <div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
+ Total Policies
+ </div>
+ <div class="h5 mb-0 font-weight-bold text-gray-800">
+ {fmt.Sprintf("%d", data.TotalPolicies)}
+ </div>
+ </div>
+ <div class="col-auto">
+ <i class="fas fa-shield-alt fa-2x text-gray-300"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-xl-4 col-md-6 mb-4">
+ <div class="card border-left-success shadow h-100 py-2">
+ <div class="card-body">
+ <div class="row no-gutters align-items-center">
+ <div class="col mr-2">
+ <div class="text-xs font-weight-bold text-success text-uppercase mb-1">
+ Active Policies
+ </div>
+ <div class="h5 mb-0 font-weight-bold text-gray-800">
+ {fmt.Sprintf("%d", data.TotalPolicies)}
+ </div>
+ </div>
+ <div class="col-auto">
+ <i class="fas fa-check-circle fa-2x text-gray-300"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-xl-4 col-md-6 mb-4">
+ <div class="card border-left-info shadow h-100 py-2">
+ <div class="card-body">
+ <div class="row no-gutters align-items-center">
+ <div class="col mr-2">
+ <div class="text-xs font-weight-bold text-info text-uppercase mb-1">
+ Last Updated
+ </div>
+ <div class="h5 mb-0 font-weight-bold text-gray-800">
+ {data.LastUpdated.Format("15:04")}
+ </div>
+ </div>
+ <div class="col-auto">
+ <i class="fas fa-clock fa-2x text-gray-300"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Policies Table -->
+ <div class="row">
+ <div class="col-12">
+ <div class="card shadow mb-4">
+ <div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
+ <h6 class="m-0 font-weight-bold text-primary">
+ <i class="fas fa-shield-alt me-2"></i>IAM Policies
+ </h6>
+ <div class="dropdown no-arrow">
+ <a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
+ <i class="fas fa-ellipsis-v fa-sm fa-fw text-gray-400"></i>
+ </a>
+ <div class="dropdown-menu dropdown-menu-right shadow animated--fade-in">
+ <div class="dropdown-header">Actions:</div>
+ <a class="dropdown-item" href="#">
+ <i class="fas fa-download me-2"></i>Export List
+ </a>
+ </div>
+ </div>
+ </div>
+ <div class="card-body">
+ <div class="table-responsive">
+ <table class="table table-hover" width="100%" cellspacing="0">
+ <thead>
+ <tr>
+ <th>Policy Name</th>
+ <th>Version</th>
+ <th>Statements</th>
+ <th>Created</th>
+ <th>Updated</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ for _, policy := range data.Policies {
+ <tr>
+ <td>
+ <strong>{policy.Name}</strong>
+ </td>
+ <td>
+ <span class="badge bg-info">{policy.Document.Version}</span>
+ </td>
+ <td>
+ <span class="badge bg-secondary">{fmt.Sprintf("%d statements", len(policy.Document.Statement))}</span>
+ </td>
+ <td>
+ <small class="text-muted">{policy.CreatedAt.Format("2006-01-02 15:04")}</small>
+ </td>
+ <td>
+ <small class="text-muted">{policy.UpdatedAt.Format("2006-01-02 15:04")}</small>
+ </td>
+ <td>
+ <div class="btn-group btn-group-sm" role="group">
+ <button type="button" class="btn btn-outline-info view-policy-btn" title="View Policy" data-policy-name={policy.Name}>
+ <i class="fas fa-eye"></i>
+ </button>
+ <button type="button" class="btn btn-outline-primary edit-policy-btn" title="Edit Policy" data-policy-name={policy.Name}>
+ <i class="fas fa-edit"></i>
+ </button>
+ <button type="button" class="btn btn-outline-danger delete-policy-btn" title="Delete Policy" data-policy-name={policy.Name}>
+ <i class="fas fa-trash"></i>
+ </button>
+ </div>
+ </td>
+ </tr>
+ }
+ if len(data.Policies) == 0 {
+ <tr>
+ <td colspan="6" class="text-center text-muted py-4">
+ <i class="fas fa-shield-alt fa-3x mb-3 text-muted"></i>
+ <div>
+ <h5>No IAM policies found</h5>
+ <p>Create your first policy to manage access permissions.</p>
+ <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createPolicyModal">
+ <i class="fas fa-plus me-1"></i>Create Policy
+ </button>
+ </div>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Create Policy Modal -->
+ <div class="modal fade" id="createPolicyModal" tabindex="-1" aria-labelledby="createPolicyModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="createPolicyModalLabel">
+ <i class="fas fa-shield-alt me-2"></i>Create IAM Policy
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <form id="createPolicyForm">
+ <div class="mb-3">
+ <label for="policyName" class="form-label">Policy Name</label>
+ <input type="text" class="form-control" id="policyName" name="name" required placeholder="e.g., S3ReadOnlyPolicy">
+ <div class="form-text">Enter a unique name for this policy (alphanumeric and underscores only)</div>
+ </div>
+
+ <div class="mb-3">
+ <label for="policyDocument" class="form-label">Policy Document</label>
+ <textarea class="form-control" id="policyDocument" name="document" rows="15" required placeholder="Enter IAM policy JSON document..."></textarea>
+ <div class="form-text">Enter the policy document in AWS IAM JSON format</div>
+ </div>
+
+ <div class="mb-3">
+ <div class="row">
+ <div class="col-md-6">
+ <button type="button" class="btn btn-outline-info btn-sm" onclick="insertSamplePolicy()">
+ <i class="fas fa-file-alt me-1"></i>Use Sample Policy
+ </button>
+ </div>
+ <div class="col-md-6 text-end">
+ <button type="button" class="btn btn-outline-secondary btn-sm" onclick="validatePolicyDocument()">
+ <i class="fas fa-check me-1"></i>Validate JSON
+ </button>
+ </div>
+ </div>
+ </div>
+ </form>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-primary" onclick="createPolicy()">
+ <i class="fas fa-plus me-1"></i>Create Policy
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- View Policy Modal -->
+ <div class="modal fade" id="viewPolicyModal" tabindex="-1" aria-labelledby="viewPolicyModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="viewPolicyModalLabel">
+ <i class="fas fa-eye me-2"></i>View IAM Policy
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <div id="viewPolicyContent">
+ <div class="text-center">
+ <div class="spinner-border" role="status">
+ <span class="visually-hidden">Loading...</span>
+ </div>
+ <p class="mt-2">Loading policy...</p>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+ <button type="button" class="btn btn-primary" id="editFromViewBtn">
+ <i class="fas fa-edit me-1"></i>Edit Policy
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Edit Policy Modal -->
+ <div class="modal fade" id="editPolicyModal" tabindex="-1" aria-labelledby="editPolicyModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="editPolicyModalLabel">
+ <i class="fas fa-edit me-2"></i>Edit IAM Policy
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <form id="editPolicyForm">
+ <div class="mb-3">
+ <label for="editPolicyName" class="form-label">Policy Name</label>
+ <input type="text" class="form-control" id="editPolicyName" name="name" readonly>
+ <div class="form-text">Policy name cannot be changed</div>
+ </div>
+
+ <div class="mb-3">
+ <label for="editPolicyDocument" class="form-label">Policy Document</label>
+ <textarea class="form-control" id="editPolicyDocument" name="document" rows="15" required></textarea>
+ <div class="form-text">Edit the policy document in AWS IAM JSON format</div>
+ </div>
+
+ <div class="mb-3">
+ <div class="row">
+ <div class="col-md-6">
+ <button type="button" class="btn btn-outline-info btn-sm" onclick="insertSamplePolicyEdit()">
+ <i class="fas fa-file-alt me-1"></i>Reset to Sample
+ </button>
+ </div>
+ <div class="col-md-6 text-end">
+ <button type="button" class="btn btn-outline-secondary btn-sm" onclick="validateEditPolicyDocument()">
+ <i class="fas fa-check me-1"></i>Validate JSON
+ </button>
+ </div>
+ </div>
+ </div>
+ </form>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-primary" onclick="updatePolicy()">
+ <i class="fas fa-save me-1"></i>Save Changes
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- JavaScript for Policy Management -->
+ <script>
+ // Current policy being viewed/edited
+ let currentPolicy = null;
+
+ // Event listeners for policy actions
+ document.addEventListener('DOMContentLoaded', function() {
+ // View policy buttons
+ document.querySelectorAll('.view-policy-btn').forEach(button => {
+ button.addEventListener('click', function() {
+ const policyName = this.getAttribute('data-policy-name');
+ viewPolicy(policyName);
+ });
+ });
+
+ // Edit policy buttons
+ document.querySelectorAll('.edit-policy-btn').forEach(button => {
+ button.addEventListener('click', function() {
+ const policyName = this.getAttribute('data-policy-name');
+ editPolicy(policyName);
+ });
+ });
+
+ // Delete policy buttons
+ document.querySelectorAll('.delete-policy-btn').forEach(button => {
+ button.addEventListener('click', function() {
+ const policyName = this.getAttribute('data-policy-name');
+ deletePolicy(policyName);
+ });
+ });
+
+ // Edit from view button
+ document.getElementById('editFromViewBtn').addEventListener('click', function() {
+ if (currentPolicy) {
+ const viewModal = bootstrap.Modal.getInstance(document.getElementById('viewPolicyModal'));
+ if (viewModal) viewModal.hide();
+ editPolicy(currentPolicy.name);
+ }
+ });
+ });
+
+ function createPolicy() {
+ const form = document.getElementById('createPolicyForm');
+ const formData = new FormData(form);
+
+ const policyName = formData.get('name');
+ const policyDocumentText = formData.get('document');
+
+ if (!policyName || !policyDocumentText) {
+ alert('Please fill in all required fields');
+ return;
+ }
+
+ let policyDocument;
+ try {
+ policyDocument = JSON.parse(policyDocumentText);
+ } catch (e) {
+ alert('Invalid JSON in policy document: ' + e.message);
+ return;
+ }
+
+ const requestData = {
+ name: policyName,
+ document: policyDocument
+ };
+
+ fetch('/api/object-store/policies', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestData)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ alert('Policy created successfully!');
+ const modal = bootstrap.Modal.getInstance(document.getElementById('createPolicyModal'));
+ if (modal) modal.hide();
+ location.reload(); // Refresh the page to show the new policy
+ } else {
+ alert('Error creating policy: ' + (data.error || 'Unknown error'));
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ alert('Error creating policy: ' + error.message);
+ });
+ }
+
+ function viewPolicy(policyName) {
+ // Show the modal first
+ const modal = new bootstrap.Modal(document.getElementById('viewPolicyModal'));
+ modal.show();
+
+ // Reset content to loading state
+ document.getElementById('viewPolicyContent').innerHTML = `
+ <div class="text-center">
+ <div class="spinner-border" role="status">
+ <span class="visually-hidden">Loading...</span>
+ </div>
+ <p class="mt-2">Loading policy...</p>
+ </div>
+ `;
+
+ // Fetch policy data
+ fetch('/api/object-store/policies/' + encodeURIComponent(policyName))
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Policy not found');
+ }
+ return response.json();
+ })
+ .then(policy => {
+ currentPolicy = policy;
+ displayPolicyDetails(policy);
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ document.getElementById('viewPolicyContent').innerHTML = `
+ <div class="alert alert-danger" role="alert">
+ <i class="fas fa-exclamation-triangle me-2"></i>
+ Error loading policy: ${error.message}
+ </div>
+ `;
+ });
+ }
+
+ function displayPolicyDetails(policy) {
+ const content = document.getElementById('viewPolicyContent');
+
+ let statementsHtml = '';
+ if (policy.document && policy.document.Statement) {
+ statementsHtml = policy.document.Statement.map((stmt, index) => `
+ <div class="card mb-2">
+ <div class="card-header py-2">
+ <h6 class="mb-0">Statement ${index + 1}</h6>
+ </div>
+ <div class="card-body py-2">
+ <div class="row">
+ <div class="col-md-6">
+ <strong>Effect:</strong>
+ <span class="badge ${stmt.Effect === 'Allow' ? 'bg-success' : 'bg-danger'}">${stmt.Effect}</span>
+ </div>
+ <div class="col-md-6">
+ <strong>Actions:</strong> ${Array.isArray(stmt.Action) ? stmt.Action.join(', ') : stmt.Action}
+ </div>
+ </div>
+ <div class="row mt-2">
+ <div class="col-12">
+ <strong>Resources:</strong> ${Array.isArray(stmt.Resource) ? stmt.Resource.join(', ') : stmt.Resource}
+ </div>
+ </div>
+ </div>
+ </div>
+ `).join('');
+ }
+
+ content.innerHTML = `
+ <div class="row mb-3">
+ <div class="col-md-6">
+ <strong>Policy Name:</strong> ${policy.name || 'Unknown'}
+ </div>
+ <div class="col-md-6">
+ <strong>Version:</strong> <span class="badge bg-info">${policy.document?.Version || 'Unknown'}</span>
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <strong>Statements:</strong>
+ <div class="mt-2">
+ ${statementsHtml || '<p class="text-muted">No statements found</p>'}
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <strong>Raw Policy Document:</strong>
+ <pre class="bg-light p-3 border rounded mt-2"><code>${JSON.stringify(policy.document, null, 2)}</code></pre>
+ </div>
+ `;
+ }
+
+ function editPolicy(policyName) {
+ // Show the modal first
+ const modal = new bootstrap.Modal(document.getElementById('editPolicyModal'));
+ modal.show();
+
+ // Set policy name
+ document.getElementById('editPolicyName').value = policyName;
+ document.getElementById('editPolicyDocument').value = 'Loading...';
+
+ // Fetch policy data
+ fetch('/api/object-store/policies/' + encodeURIComponent(policyName))
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Policy not found');
+ }
+ return response.json();
+ })
+ .then(policy => {
+ currentPolicy = policy;
+ document.getElementById('editPolicyDocument').value = JSON.stringify(policy.document, null, 2);
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ alert('Error loading policy for editing: ' + error.message);
+ const editModal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));
+ if (editModal) editModal.hide();
+ });
+ }
+
+ function updatePolicy() {
+ const policyName = document.getElementById('editPolicyName').value;
+ const policyDocumentText = document.getElementById('editPolicyDocument').value;
+
+ if (!policyName || !policyDocumentText) {
+ alert('Please fill in all required fields');
+ return;
+ }
+
+ let policyDocument;
+ try {
+ policyDocument = JSON.parse(policyDocumentText);
+ } catch (e) {
+ alert('Invalid JSON in policy document: ' + e.message);
+ return;
+ }
+
+ const requestData = {
+ document: policyDocument
+ };
+
+ fetch('/api/object-store/policies/' + encodeURIComponent(policyName), {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestData)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ alert('Policy updated successfully!');
+ const modal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));
+ if (modal) modal.hide();
+ location.reload(); // Refresh the page to show the updated policy
+ } else {
+ alert('Error updating policy: ' + (data.error || 'Unknown error'));
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ alert('Error updating policy: ' + error.message);
+ });
+ }
+
+ function insertSamplePolicy() {
+ const samplePolicy = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": [
+ "s3:GetObject",
+ "s3:PutObject"
+ ],
+ "Resource": [
+ "arn:aws:s3:::my-bucket/*"
+ ]
+ }
+ ]
+ };
+
+ document.getElementById('policyDocument').value = JSON.stringify(samplePolicy, null, 2);
+ }
+
+ function insertSamplePolicyEdit() {
+ const samplePolicy = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": [
+ "s3:GetObject",
+ "s3:PutObject"
+ ],
+ "Resource": [
+ "arn:aws:s3:::my-bucket/*"
+ ]
+ }
+ ]
+ };
+
+ document.getElementById('editPolicyDocument').value = JSON.stringify(samplePolicy, null, 2);
+ }
+
+ function validatePolicyDocument() {
+ const policyText = document.getElementById('policyDocument').value;
+ validatePolicyJSON(policyText);
+ }
+
+ function validateEditPolicyDocument() {
+ const policyText = document.getElementById('editPolicyDocument').value;
+ validatePolicyJSON(policyText);
+ }
+
+ function validatePolicyJSON(policyText) {
+ if (!policyText) {
+ alert('Please enter a policy document first');
+ return;
+ }
+
+ try {
+ const policy = JSON.parse(policyText);
+
+ // Basic validation
+ if (!policy.Version) {
+ alert('Policy must have a Version field');
+ return;
+ }
+
+ if (!policy.Statement || !Array.isArray(policy.Statement)) {
+ alert('Policy must have a Statement array');
+ return;
+ }
+
+ alert('Policy document is valid JSON!');
+ } catch (e) {
+ alert('Invalid JSON: ' + e.message);
+ }
+ }
+
+ function deletePolicy(policyName) {
+ if (confirm('Are you sure you want to delete policy "' + policyName + '"?')) {
+ fetch('/api/object-store/policies/' + encodeURIComponent(policyName), {
+ method: 'DELETE'
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ alert('Policy deleted successfully!');
+ location.reload(); // Refresh the page
+ } else {
+ alert('Error deleting policy: ' + (data.error || 'Unknown error'));
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ alert('Error deleting policy: ' + error.message);
+ });
+ }
+ }
+ </script>
+} \ No newline at end of file
diff --git a/weed/admin/view/app/policies_templ.go b/weed/admin/view/app/policies_templ.go
new file mode 100644
index 000000000..2e005fb58
--- /dev/null
+++ b/weed/admin/view/app/policies_templ.go
@@ -0,0 +1,204 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.906
+package app
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "github.com/seaweedfs/seaweedfs/weed/admin/dash"
+)
+
+func Policies(data dash.PoliciesData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-shield-alt me-2\"></i>IAM Policies</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\"><button type=\"button\" class=\"btn btn-sm btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#createPolicyModal\"><i class=\"fas fa-plus me-1\"></i>Create Policy</button></div></div></div><div id=\"policies-content\"><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Policies</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalPolicies))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 34, Col: 74}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></div><div class=\"col-auto\"><i class=\"fas fa-shield-alt fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-success shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-success text-uppercase mb-1\">Active Policies</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalPolicies))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 54, Col: 74}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><div class=\"col-auto\"><i class=\"fas fa-check-circle fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-4 col-md-6 mb-4\"><div class=\"card border-left-info shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Last Updated</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("15:04"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 74, Col: 69}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div><div class=\"col-auto\"><i class=\"fas fa-clock fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Policies Table --><div class=\"row\"><div class=\"col-12\"><div class=\"card shadow mb-4\"><div class=\"card-header py-3 d-flex flex-row align-items-center justify-content-between\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-shield-alt me-2\"></i>IAM Policies</h6><div class=\"dropdown no-arrow\"><a class=\"dropdown-toggle\" href=\"#\" role=\"button\" data-bs-toggle=\"dropdown\"><i class=\"fas fa-ellipsis-v fa-sm fa-fw text-gray-400\"></i></a><div class=\"dropdown-menu dropdown-menu-right shadow animated--fade-in\"><div class=\"dropdown-header\">Actions:</div><a class=\"dropdown-item\" href=\"#\"><i class=\"fas fa-download me-2\"></i>Export List</a></div></div></div><div class=\"card-body\"><div class=\"table-responsive\"><table class=\"table table-hover\" width=\"100%\" cellspacing=\"0\"><thead><tr><th>Policy Name</th><th>Version</th><th>Statements</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead> <tbody>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, policy := range data.Policies {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<tr><td><strong>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 123, Col: 68}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</strong></td><td><span class=\"badge bg-info\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Document.Version)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 126, Col: 100}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span></td><td><span class=\"badge bg-secondary\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d statements", len(policy.Document.Statement)))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 129, Col: 142}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></td><td><small class=\"text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(policy.CreatedAt.Format("2006-01-02 15:04"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 132, Col: 118}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</small></td><td><small class=\"text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(policy.UpdatedAt.Format("2006-01-02 15:04"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 135, Col: 118}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</small></td><td><div class=\"btn-group btn-group-sm\" role=\"group\"><button type=\"button\" class=\"btn btn-outline-info view-policy-btn\" title=\"View Policy\" data-policy-name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 139, Col: 168}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-primary edit-policy-btn\" title=\"Edit Policy\" data-policy-name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 142, Col: 171}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><i class=\"fas fa-edit\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger delete-policy-btn\" title=\"Delete Policy\" data-policy-name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(policy.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/policies.templ`, Line: 145, Col: 174}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if len(data.Policies) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<tr><td colspan=\"6\" class=\"text-center text-muted py-4\"><i class=\"fas fa-shield-alt fa-3x mb-3 text-muted\"></i><div><h5>No IAM policies found</h5><p>Create your first policy to manage access permissions.</p><button type=\"button\" class=\"btn btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#createPolicyModal\"><i class=\"fas fa-plus me-1\"></i>Create Policy</button></div></td></tr>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</tbody></table></div></div></div></div></div></div><!-- Create Policy Modal --><div class=\"modal fade\" id=\"createPolicyModal\" tabindex=\"-1\" aria-labelledby=\"createPolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createPolicyModalLabel\"><i class=\"fas fa-shield-alt me-2\"></i>Create IAM Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"createPolicyForm\"><div class=\"mb-3\"><label for=\"policyName\" class=\"form-label\">Policy Name</label> <input type=\"text\" class=\"form-control\" id=\"policyName\" name=\"name\" required placeholder=\"e.g., S3ReadOnlyPolicy\"><div class=\"form-text\">Enter a unique name for this policy (alphanumeric and underscores only)</div></div><div class=\"mb-3\"><label for=\"policyDocument\" class=\"form-label\">Policy Document</label> <textarea class=\"form-control\" id=\"policyDocument\" name=\"document\" rows=\"15\" required placeholder=\"Enter IAM policy JSON document...\"></textarea><div class=\"form-text\">Enter the policy document in AWS IAM JSON format</div></div><div class=\"mb-3\"><div class=\"row\"><div class=\"col-md-6\"><button type=\"button\" class=\"btn btn-outline-info btn-sm\" onclick=\"insertSamplePolicy()\"><i class=\"fas fa-file-alt me-1\"></i>Use Sample Policy</button></div><div class=\"col-md-6 text-end\"><button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" onclick=\"validatePolicyDocument()\"><i class=\"fas fa-check me-1\"></i>Validate JSON</button></div></div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"createPolicy()\"><i class=\"fas fa-plus me-1\"></i>Create Policy</button></div></div></div></div><!-- View Policy Modal --><div class=\"modal fade\" id=\"viewPolicyModal\" tabindex=\"-1\" aria-labelledby=\"viewPolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"viewPolicyModalLabel\"><i class=\"fas fa-eye me-2\"></i>View IAM Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><div id=\"viewPolicyContent\"><div class=\"text-center\"><div class=\"spinner-border\" role=\"status\"><span class=\"visually-hidden\">Loading...</span></div><p class=\"mt-2\">Loading policy...</p></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button> <button type=\"button\" class=\"btn btn-primary\" id=\"editFromViewBtn\"><i class=\"fas fa-edit me-1\"></i>Edit Policy</button></div></div></div></div><!-- Edit Policy Modal --><div class=\"modal fade\" id=\"editPolicyModal\" tabindex=\"-1\" aria-labelledby=\"editPolicyModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"editPolicyModalLabel\"><i class=\"fas fa-edit me-2\"></i>Edit IAM Policy</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><form id=\"editPolicyForm\"><div class=\"mb-3\"><label for=\"editPolicyName\" class=\"form-label\">Policy Name</label> <input type=\"text\" class=\"form-control\" id=\"editPolicyName\" name=\"name\" readonly><div class=\"form-text\">Policy name cannot be changed</div></div><div class=\"mb-3\"><label for=\"editPolicyDocument\" class=\"form-label\">Policy Document</label> <textarea class=\"form-control\" id=\"editPolicyDocument\" name=\"document\" rows=\"15\" required></textarea><div class=\"form-text\">Edit the policy document in AWS IAM JSON format</div></div><div class=\"mb-3\"><div class=\"row\"><div class=\"col-md-6\"><button type=\"button\" class=\"btn btn-outline-info btn-sm\" onclick=\"insertSamplePolicyEdit()\"><i class=\"fas fa-file-alt me-1\"></i>Reset to Sample</button></div><div class=\"col-md-6 text-end\"><button type=\"button\" class=\"btn btn-outline-secondary btn-sm\" onclick=\"validateEditPolicyDocument()\"><i class=\"fas fa-check me-1\"></i>Validate JSON</button></div></div></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"updatePolicy()\"><i class=\"fas fa-save me-1\"></i>Save Changes</button></div></div></div></div><!-- JavaScript for Policy Management --><script>\n // Current policy being viewed/edited\n let currentPolicy = null;\n \n // Event listeners for policy actions\n document.addEventListener('DOMContentLoaded', function() {\n // View policy buttons\n document.querySelectorAll('.view-policy-btn').forEach(button => {\n button.addEventListener('click', function() {\n const policyName = this.getAttribute('data-policy-name');\n viewPolicy(policyName);\n });\n });\n \n // Edit policy buttons\n document.querySelectorAll('.edit-policy-btn').forEach(button => {\n button.addEventListener('click', function() {\n const policyName = this.getAttribute('data-policy-name');\n editPolicy(policyName);\n });\n });\n \n // Delete policy buttons\n document.querySelectorAll('.delete-policy-btn').forEach(button => {\n button.addEventListener('click', function() {\n const policyName = this.getAttribute('data-policy-name');\n deletePolicy(policyName);\n });\n });\n \n // Edit from view button\n document.getElementById('editFromViewBtn').addEventListener('click', function() {\n if (currentPolicy) {\n const viewModal = bootstrap.Modal.getInstance(document.getElementById('viewPolicyModal'));\n if (viewModal) viewModal.hide();\n editPolicy(currentPolicy.name);\n }\n });\n });\n \n function createPolicy() {\n const form = document.getElementById('createPolicyForm');\n const formData = new FormData(form);\n \n const policyName = formData.get('name');\n const policyDocumentText = formData.get('document');\n \n if (!policyName || !policyDocumentText) {\n alert('Please fill in all required fields');\n return;\n }\n \n let policyDocument;\n try {\n policyDocument = JSON.parse(policyDocumentText);\n } catch (e) {\n alert('Invalid JSON in policy document: ' + e.message);\n return;\n }\n \n const requestData = {\n name: policyName,\n document: policyDocument\n };\n \n fetch('/api/object-store/policies', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(requestData)\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Policy created successfully!');\n const modal = bootstrap.Modal.getInstance(document.getElementById('createPolicyModal'));\n if (modal) modal.hide();\n location.reload(); // Refresh the page to show the new policy\n } else {\n alert('Error creating policy: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error creating policy: ' + error.message);\n });\n }\n \n function viewPolicy(policyName) {\n // Show the modal first\n const modal = new bootstrap.Modal(document.getElementById('viewPolicyModal'));\n modal.show();\n \n // Reset content to loading state\n document.getElementById('viewPolicyContent').innerHTML = `\n <div class=\"text-center\">\n <div class=\"spinner-border\" role=\"status\">\n <span class=\"visually-hidden\">Loading...</span>\n </div>\n <p class=\"mt-2\">Loading policy...</p>\n </div>\n `;\n \n // Fetch policy data\n fetch('/api/object-store/policies/' + encodeURIComponent(policyName))\n .then(response => {\n if (!response.ok) {\n throw new Error('Policy not found');\n }\n return response.json();\n })\n .then(policy => {\n currentPolicy = policy;\n displayPolicyDetails(policy);\n })\n .catch(error => {\n console.error('Error:', error);\n document.getElementById('viewPolicyContent').innerHTML = `\n <div class=\"alert alert-danger\" role=\"alert\">\n <i class=\"fas fa-exclamation-triangle me-2\"></i>\n Error loading policy: ${error.message}\n </div>\n `;\n });\n }\n \n function displayPolicyDetails(policy) {\n const content = document.getElementById('viewPolicyContent');\n \n let statementsHtml = '';\n if (policy.document && policy.document.Statement) {\n statementsHtml = policy.document.Statement.map((stmt, index) => `\n <div class=\"card mb-2\">\n <div class=\"card-header py-2\">\n <h6 class=\"mb-0\">Statement ${index + 1}</h6>\n </div>\n <div class=\"card-body py-2\">\n <div class=\"row\">\n <div class=\"col-md-6\">\n <strong>Effect:</strong> \n <span class=\"badge ${stmt.Effect === 'Allow' ? 'bg-success' : 'bg-danger'}\">${stmt.Effect}</span>\n </div>\n <div class=\"col-md-6\">\n <strong>Actions:</strong> ${Array.isArray(stmt.Action) ? stmt.Action.join(', ') : stmt.Action}\n </div>\n </div>\n <div class=\"row mt-2\">\n <div class=\"col-12\">\n <strong>Resources:</strong> ${Array.isArray(stmt.Resource) ? stmt.Resource.join(', ') : stmt.Resource}\n </div>\n </div>\n </div>\n </div>\n `).join('');\n }\n \n content.innerHTML = `\n <div class=\"row mb-3\">\n <div class=\"col-md-6\">\n <strong>Policy Name:</strong> ${policy.name || 'Unknown'}\n </div>\n <div class=\"col-md-6\">\n <strong>Version:</strong> <span class=\"badge bg-info\">${policy.document?.Version || 'Unknown'}</span>\n </div>\n </div>\n \n <div class=\"mb-3\">\n <strong>Statements:</strong>\n <div class=\"mt-2\">\n ${statementsHtml || '<p class=\"text-muted\">No statements found</p>'}\n </div>\n </div>\n \n <div class=\"mb-3\">\n <strong>Raw Policy Document:</strong>\n <pre class=\"bg-light p-3 border rounded mt-2\"><code>${JSON.stringify(policy.document, null, 2)}</code></pre>\n </div>\n `;\n }\n \n function editPolicy(policyName) {\n // Show the modal first\n const modal = new bootstrap.Modal(document.getElementById('editPolicyModal'));\n modal.show();\n \n // Set policy name\n document.getElementById('editPolicyName').value = policyName;\n document.getElementById('editPolicyDocument').value = 'Loading...';\n \n // Fetch policy data\n fetch('/api/object-store/policies/' + encodeURIComponent(policyName))\n .then(response => {\n if (!response.ok) {\n throw new Error('Policy not found');\n }\n return response.json();\n })\n .then(policy => {\n currentPolicy = policy;\n document.getElementById('editPolicyDocument').value = JSON.stringify(policy.document, null, 2);\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error loading policy for editing: ' + error.message);\n const editModal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));\n if (editModal) editModal.hide();\n });\n }\n \n function updatePolicy() {\n const policyName = document.getElementById('editPolicyName').value;\n const policyDocumentText = document.getElementById('editPolicyDocument').value;\n \n if (!policyName || !policyDocumentText) {\n alert('Please fill in all required fields');\n return;\n }\n \n let policyDocument;\n try {\n policyDocument = JSON.parse(policyDocumentText);\n } catch (e) {\n alert('Invalid JSON in policy document: ' + e.message);\n return;\n }\n \n const requestData = {\n document: policyDocument\n };\n \n fetch('/api/object-store/policies/' + encodeURIComponent(policyName), {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(requestData)\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Policy updated successfully!');\n const modal = bootstrap.Modal.getInstance(document.getElementById('editPolicyModal'));\n if (modal) modal.hide();\n location.reload(); // Refresh the page to show the updated policy\n } else {\n alert('Error updating policy: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error updating policy: ' + error.message);\n });\n }\n \n function insertSamplePolicy() {\n const samplePolicy = {\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"s3:GetObject\",\n \"s3:PutObject\"\n ],\n \"Resource\": [\n \"arn:aws:s3:::my-bucket/*\"\n ]\n }\n ]\n };\n \n document.getElementById('policyDocument').value = JSON.stringify(samplePolicy, null, 2);\n }\n \n function insertSamplePolicyEdit() {\n const samplePolicy = {\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"s3:GetObject\",\n \"s3:PutObject\"\n ],\n \"Resource\": [\n \"arn:aws:s3:::my-bucket/*\"\n ]\n }\n ]\n };\n \n document.getElementById('editPolicyDocument').value = JSON.stringify(samplePolicy, null, 2);\n }\n \n function validatePolicyDocument() {\n const policyText = document.getElementById('policyDocument').value;\n validatePolicyJSON(policyText);\n }\n \n function validateEditPolicyDocument() {\n const policyText = document.getElementById('editPolicyDocument').value;\n validatePolicyJSON(policyText);\n }\n \n function validatePolicyJSON(policyText) {\n if (!policyText) {\n alert('Please enter a policy document first');\n return;\n }\n \n try {\n const policy = JSON.parse(policyText);\n \n // Basic validation\n if (!policy.Version) {\n alert('Policy must have a Version field');\n return;\n }\n \n if (!policy.Statement || !Array.isArray(policy.Statement)) {\n alert('Policy must have a Statement array');\n return;\n }\n \n alert('Policy document is valid JSON!');\n } catch (e) {\n alert('Invalid JSON: ' + e.message);\n }\n }\n \n function deletePolicy(policyName) {\n if (confirm('Are you sure you want to delete policy \"' + policyName + '\"?')) {\n fetch('/api/object-store/policies/' + encodeURIComponent(policyName), {\n method: 'DELETE'\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Policy deleted successfully!');\n location.reload(); // Refresh the page\n } else {\n alert('Error deleting policy: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error deleting policy: ' + error.message);\n });\n }\n }\n </script>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/weed/admin/view/app/s3_buckets.templ b/weed/admin/view/app/s3_buckets.templ
index d6625f5e8..1afafb294 100644
--- a/weed/admin/view/app/s3_buckets.templ
+++ b/weed/admin/view/app/s3_buckets.templ
@@ -187,11 +187,12 @@ templ S3Buckets(data dash.S3BucketsData) {
title="Browse Files">
<i class="fas fa-folder-open"></i>
</a>
- <a href={templ.SafeURL(fmt.Sprintf("/s3/buckets/%s", bucket.Name))}
- class="btn btn-outline-primary btn-sm"
- title="View Details">
+ <button type="button"
+ class="btn btn-outline-primary btn-sm view-details-btn"
+ data-bucket-name={bucket.Name}
+ title="View Details">
<i class="fas fa-eye"></i>
- </a>
+ </button>
<button type="button"
class="btn btn-outline-warning btn-sm quota-btn"
data-bucket-name={bucket.Name}
@@ -442,6 +443,33 @@ templ S3Buckets(data dash.S3BucketsData) {
</div>
</div>
+ <!-- Bucket Details Modal -->
+ <div class="modal fade" id="bucketDetailsModal" tabindex="-1" aria-labelledby="bucketDetailsModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="bucketDetailsModalLabel">
+ <i class="fas fa-cube me-2"></i>Bucket Details
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <div id="bucketDetailsContent">
+ <div class="text-center py-4">
+ <div class="spinner-border text-primary" role="status">
+ <span class="visually-hidden">Loading...</span>
+ </div>
+ <div class="mt-2">Loading bucket details...</div>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
<!-- JavaScript for bucket management -->
<script>
document.addEventListener('DOMContentLoaded', function() {
@@ -504,7 +532,12 @@ templ S3Buckets(data dash.S3BucketsData) {
alert('Error creating bucket: ' + data.error);
} else {
alert('Bucket created successfully!');
- location.reload();
+ // Properly close the modal before reloading
+ const createModal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal'));
+ if (createModal) {
+ createModal.hide();
+ }
+ setTimeout(() => location.reload(), 500);
}
})
.catch(error => {
@@ -514,16 +547,41 @@ templ S3Buckets(data dash.S3BucketsData) {
});
// Handle delete bucket
+ let deleteModalInstance = null;
document.querySelectorAll('.delete-bucket-btn').forEach(button => {
button.addEventListener('click', function() {
const bucketName = this.dataset.bucketName;
document.getElementById('deleteBucketName').textContent = bucketName;
window.currentBucketToDelete = bucketName;
- new bootstrap.Modal(document.getElementById('deleteBucketModal')).show();
+
+ // Dispose of existing modal instance if it exists
+ if (deleteModalInstance) {
+ deleteModalInstance.dispose();
+ }
+
+ // Create new modal instance
+ deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));
+ deleteModalInstance.show();
+ });
+ });
+
+ // Add event listener to properly dispose of delete modal when hidden
+ document.getElementById('deleteBucketModal').addEventListener('hidden.bs.modal', function() {
+ if (deleteModalInstance) {
+ deleteModalInstance.dispose();
+ deleteModalInstance = null;
+ }
+ // Force remove any remaining backdrops
+ document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
+ backdrop.remove();
});
+ // Ensure body classes are removed
+ document.body.classList.remove('modal-open');
+ document.body.style.removeProperty('padding-right');
});
// Handle quota management
+ let quotaModalInstance = null;
document.querySelectorAll('.quota-btn').forEach(button => {
button.addEventListener('click', function() {
const bucketName = this.dataset.bucketName;
@@ -538,10 +596,33 @@ templ S3Buckets(data dash.S3BucketsData) {
document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';
window.currentBucketToUpdate = bucketName;
- new bootstrap.Modal(document.getElementById('manageQuotaModal')).show();
+
+ // Dispose of existing modal instance if it exists
+ if (quotaModalInstance) {
+ quotaModalInstance.dispose();
+ }
+
+ // Create new modal instance
+ quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));
+ quotaModalInstance.show();
});
});
+ // Add event listener to properly dispose of quota modal when hidden
+ document.getElementById('manageQuotaModal').addEventListener('hidden.bs.modal', function() {
+ if (quotaModalInstance) {
+ quotaModalInstance.dispose();
+ quotaModalInstance = null;
+ }
+ // Force remove any remaining backdrops
+ document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
+ backdrop.remove();
+ });
+ // Ensure body classes are removed
+ document.body.classList.remove('modal-open');
+ document.body.style.removeProperty('padding-right');
+ });
+
// Handle quota form submission
document.getElementById('quotaForm').addEventListener('submit', function(e) {
e.preventDefault();
@@ -567,7 +648,11 @@ templ S3Buckets(data dash.S3BucketsData) {
alert('Error updating quota: ' + data.error);
} else {
alert('Quota updated successfully!');
- location.reload();
+ // Properly close the modal before reloading
+ if (quotaModalInstance) {
+ quotaModalInstance.hide();
+ }
+ setTimeout(() => location.reload(), 500);
}
})
.catch(error => {
@@ -580,6 +665,74 @@ templ S3Buckets(data dash.S3BucketsData) {
document.getElementById('quotaEnabled').addEventListener('change', function() {
document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';
});
+
+ // Handle view details button
+ let detailsModalInstance = null;
+ document.querySelectorAll('.view-details-btn').forEach(button => {
+ button.addEventListener('click', function() {
+ const bucketName = this.dataset.bucketName;
+
+ // Update modal title
+ document.getElementById('bucketDetailsModalLabel').innerHTML =
+ '<i class="fas fa-cube me-2"></i>Bucket Details - ' + bucketName;
+
+ // Show loading spinner
+ document.getElementById('bucketDetailsContent').innerHTML =
+ '<div class="text-center py-4">' +
+ '<div class="spinner-border text-primary" role="status">' +
+ '<span class="visually-hidden">Loading...</span>' +
+ '</div>' +
+ '<div class="mt-2">Loading bucket details...</div>' +
+ '</div>';
+
+ // Dispose of existing modal instance if it exists
+ if (detailsModalInstance) {
+ detailsModalInstance.dispose();
+ }
+
+ // Create new modal instance
+ detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));
+ detailsModalInstance.show();
+
+ // Fetch bucket details
+ fetch('/api/s3/buckets/' + bucketName)
+ .then(response => response.json())
+ .then(data => {
+ if (data.error) {
+ document.getElementById('bucketDetailsContent').innerHTML =
+ '<div class="alert alert-danger">' +
+ '<i class="fas fa-exclamation-triangle me-2"></i>' +
+ 'Error loading bucket details: ' + data.error +
+ '</div>';
+ } else {
+ displayBucketDetails(data);
+ }
+ })
+ .catch(error => {
+ console.error('Error fetching bucket details:', error);
+ document.getElementById('bucketDetailsContent').innerHTML =
+ '<div class="alert alert-danger">' +
+ '<i class="fas fa-exclamation-triangle me-2"></i>' +
+ 'Error loading bucket details: ' + error.message +
+ '</div>';
+ });
+ });
+ });
+
+ // Add event listener to properly dispose of details modal when hidden
+ document.getElementById('bucketDetailsModal').addEventListener('hidden.bs.modal', function() {
+ if (detailsModalInstance) {
+ detailsModalInstance.dispose();
+ detailsModalInstance = null;
+ }
+ // Force remove any remaining backdrops
+ document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
+ backdrop.remove();
+ });
+ // Ensure body classes are removed
+ document.body.classList.remove('modal-open');
+ document.body.style.removeProperty('padding-right');
+ });
});
function deleteBucket() {
@@ -595,7 +748,11 @@ templ S3Buckets(data dash.S3BucketsData) {
alert('Error deleting bucket: ' + data.error);
} else {
alert('Bucket deleted successfully!');
- location.reload();
+ // Properly close the modal before reloading
+ if (deleteModalInstance) {
+ deleteModalInstance.hide();
+ }
+ setTimeout(() => location.reload(), 500);
}
})
.catch(error => {
@@ -604,6 +761,128 @@ templ S3Buckets(data dash.S3BucketsData) {
});
}
+ function displayBucketDetails(data) {
+ const bucket = data.bucket;
+ const objects = data.objects || [];
+
+ // Helper function to format bytes
+ function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ // Helper function to format date
+ function formatDate(dateString) {
+ const date = new Date(dateString);
+ return date.toLocaleString();
+ }
+
+ // Generate objects table
+ let objectsTable = '';
+ if (objects.length > 0) {
+ objectsTable = '<div class="table-responsive">' +
+ '<table class="table table-sm table-striped">' +
+ '<thead>' +
+ '<tr>' +
+ '<th>Object Key</th>' +
+ '<th>Size</th>' +
+ '<th>Last Modified</th>' +
+ '<th>Storage Class</th>' +
+ '</tr>' +
+ '</thead>' +
+ '<tbody>' +
+ objects.map(obj =>
+ '<tr>' +
+ '<td><i class="fas fa-file me-1"></i>' + obj.key + '</td>' +
+ '<td>' + formatBytes(obj.size) + '</td>' +
+ '<td>' + formatDate(obj.last_modified) + '</td>' +
+ '<td><span class="badge bg-primary">' + obj.storage_class + '</span></td>' +
+ '</tr>'
+ ).join('') +
+ '</tbody>' +
+ '</table>' +
+ '</div>';
+ } else {
+ objectsTable = '<div class="text-center py-4 text-muted">' +
+ '<i class="fas fa-file fa-3x mb-3"></i>' +
+ '<div>No objects found in this bucket</div>' +
+ '</div>';
+ }
+
+ const content = '<div class="row">' +
+ '<div class="col-md-6">' +
+ '<h6><i class="fas fa-info-circle me-2"></i>Bucket Information</h6>' +
+ '<table class="table table-sm">' +
+ '<tr>' +
+ '<td><strong>Name:</strong></td>' +
+ '<td>' + bucket.name + '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Created:</strong></td>' +
+ '<td>' + formatDate(bucket.created_at) + '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Last Modified:</strong></td>' +
+ '<td>' + formatDate(bucket.last_modified) + '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Total Size:</strong></td>' +
+ '<td>' + formatBytes(bucket.size) + '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Object Count:</strong></td>' +
+ '<td>' + bucket.object_count + '</td>' +
+ '</tr>' +
+ '</table>' +
+ '</div>' +
+ '<div class="col-md-6">' +
+ '<h6><i class="fas fa-cogs me-2"></i>Configuration</h6>' +
+ '<table class="table table-sm">' +
+ '<tr>' +
+ '<td><strong>Quota:</strong></td>' +
+ '<td>' +
+ (bucket.quota_enabled ?
+ '<span class="badge bg-success">' + formatBytes(bucket.quota) + '</span>' :
+ '<span class="badge bg-secondary">Disabled</span>'
+ ) +
+ '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Versioning:</strong></td>' +
+ '<td>' +
+ (bucket.versioning_enabled ?
+ '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Enabled</span>' :
+ '<span class="badge bg-secondary"><i class="fas fa-times me-1"></i>Disabled</span>'
+ ) +
+ '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Object Lock:</strong></td>' +
+ '<td>' +
+ (bucket.object_lock_enabled ?
+ '<span class="badge bg-warning"><i class="fas fa-lock me-1"></i>Enabled</span>' +
+ '<br><small class="text-muted">' + bucket.object_lock_mode + ' • ' + bucket.object_lock_duration + ' days</small>' :
+ '<span class="badge bg-secondary"><i class="fas fa-unlock me-1"></i>Disabled</span>'
+ ) +
+ '</td>' +
+ '</tr>' +
+ '</table>' +
+ '</div>' +
+ '</div>' +
+ '<hr>' +
+ '<div class="row">' +
+ '<div class="col-12">' +
+ '<h6><i class="fas fa-list me-2"></i>Objects (' + objects.length + ')</h6>' +
+ objectsTable +
+ '</div>' +
+ '</div>';
+
+ document.getElementById('bucketDetailsContent').innerHTML = content;
+ }
+
function exportBucketList() {
// Simple CSV export
const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {
@@ -624,7 +903,7 @@ templ S3Buckets(data dash.S3BucketsData) {
const csvContent = "data:text/csv;charset=utf-8," +
"Name,Created,Objects,Size,Quota,Versioning,Object Lock\n" +
- buckets.map(b => `"${b.name}","${b.created}","${b.objects}","${b.size}","${b.quota}","${b.versioning}","${b.objectLock}"`).join("\n");
+ buckets.map(b => '"' + b.name + '","' + b.created + '","' + b.objects + '","' + b.size + '","' + b.quota + '","' + b.versioning + '","' + b.objectLock + '"').join("\n");
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
diff --git a/weed/admin/view/app/s3_buckets_templ.go b/weed/admin/view/app/s3_buckets_templ.go
index 0912a51c5..6edb5d371 100644
--- a/weed/admin/view/app/s3_buckets_templ.go
+++ b/weed/admin/view/app/s3_buckets_templ.go
@@ -290,27 +290,27 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"btn btn-outline-success btn-sm\" title=\"Browse Files\"><i class=\"fas fa-folder-open\"></i></a> <a href=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"btn btn-outline-success btn-sm\" title=\"Browse Files\"><i class=\"fas fa-folder-open\"></i></a> <button type=\"button\" class=\"btn btn-outline-primary btn-sm view-details-btn\" data-bucket-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var17 templ.SafeURL
- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/s3/buckets/%s", bucket.Name)))
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 190, Col: 118}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 192, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" class=\"btn btn-outline-primary btn-sm\" title=\"View Details\"><i class=\"fas fa-eye\"></i></a> <button type=\"button\" class=\"btn btn-outline-warning btn-sm quota-btn\" data-bucket-name=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-warning btn-sm quota-btn\" data-bucket-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 197, Col: 89}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 198, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -323,7 +323,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", getQuotaInMB(bucket.Quota)))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 198, Col: 125}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 199, Col: 125}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -336,7 +336,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", bucket.QuotaEnabled))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 199, Col: 118}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 200, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -349,7 +349,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 205, Col: 89}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 206, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
@@ -373,13 +373,13 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 242, Col: 81}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 243, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</small></div></div></div><!-- Create Bucket Modal --><div class=\"modal fade\" id=\"createBucketModal\" tabindex=\"-1\" aria-labelledby=\"createBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createBucketModalLabel\"><i class=\"fas fa-plus me-2\"></i>Create New S3 Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"createBucketForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label for=\"bucketName\" class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"bucketName\" name=\"name\" placeholder=\"my-bucket-name\" required pattern=\"[a-z0-9.-]+\" title=\"Bucket name must contain only lowercase letters, numbers, dots, and hyphens\"><div class=\"form-text\">Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableQuota\" name=\"quota_enabled\"> <label class=\"form-check-label\" for=\"enableQuota\">Enable Storage Quota</label></div></div><div class=\"mb-3\" id=\"quotaSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-8\"><label for=\"quotaSize\" class=\"form-label\">Quota Size</label> <input type=\"number\" class=\"form-control\" id=\"quotaSize\" name=\"quota_size\" placeholder=\"1024\" min=\"1\" step=\"1\"></div><div class=\"col-md-4\"><label for=\"quotaUnit\" class=\"form-label\">Unit</label> <select class=\"form-select\" id=\"quotaUnit\" name=\"quota_unit\"><option value=\"MB\" selected>MB</option> <option value=\"GB\">GB</option> <option value=\"TB\">TB</option></select></div></div><div class=\"form-text\">Set the maximum storage size for this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableVersioning\" name=\"versioning_enabled\"> <label class=\"form-check-label\" for=\"enableVersioning\">Enable Object Versioning</label></div><div class=\"form-text\">Keep multiple versions of objects in this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableObjectLock\" name=\"object_lock_enabled\"> <label class=\"form-check-label\" for=\"enableObjectLock\">Enable Object Lock</label></div><div class=\"form-text\">Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.</div></div><div class=\"mb-3\" id=\"objectLockSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-6\"><label for=\"objectLockMode\" class=\"form-label\">Object Lock Mode</label> <select class=\"form-select\" id=\"objectLockMode\" name=\"object_lock_mode\"><option value=\"GOVERNANCE\" selected>Governance</option> <option value=\"COMPLIANCE\">Compliance</option></select><div class=\"form-text\">Governance allows override with special permissions, Compliance is immutable.</div></div><div class=\"col-md-6\"><label for=\"objectLockDuration\" class=\"form-label\">Default Retention (days)</label> <input type=\"number\" class=\"form-control\" id=\"objectLockDuration\" name=\"object_lock_duration\" placeholder=\"30\" min=\"1\" max=\"36500\" step=\"1\"><div class=\"form-text\">Default retention period for new objects (1-36500 days).</div></div></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-plus me-1\"></i>Create Bucket</button></div></form></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteBucketModal\" tabindex=\"-1\" aria-labelledby=\"deleteBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"deleteBucketModalLabel\"><i class=\"fas fa-exclamation-triangle me-2 text-warning\"></i>Delete Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the bucket <strong id=\"deleteBucketName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-exclamation-triangle me-2\"></i> <strong>Warning:</strong> This action cannot be undone. All objects in the bucket will be permanently deleted.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" onclick=\"deleteBucket()\"><i class=\"fas fa-trash me-1\"></i>Delete Bucket</button></div></div></div></div><!-- Manage Quota Modal --><div class=\"modal fade\" id=\"manageQuotaModal\" tabindex=\"-1\" aria-labelledby=\"manageQuotaModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"manageQuotaModalLabel\"><i class=\"fas fa-tachometer-alt me-2\"></i>Manage Bucket Quota</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"quotaForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"quotaBucketName\" readonly></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"quotaEnabled\" name=\"quota_enabled\"> <label class=\"form-check-label\" for=\"quotaEnabled\">Enable Storage Quota</label></div></div><div class=\"mb-3\" id=\"quotaSizeSettings\"><div class=\"row\"><div class=\"col-md-8\"><label for=\"quotaSizeMB\" class=\"form-label\">Quota Size</label> <input type=\"number\" class=\"form-control\" id=\"quotaSizeMB\" name=\"quota_size\" placeholder=\"1024\" min=\"0\" step=\"1\"></div><div class=\"col-md-4\"><label for=\"quotaUnitMB\" class=\"form-label\">Unit</label> <select class=\"form-select\" id=\"quotaUnitMB\" name=\"quota_unit\"><option value=\"MB\" selected>MB</option> <option value=\"GB\">GB</option> <option value=\"TB\">TB</option></select></div></div><div class=\"form-text\">Set the maximum storage size for this bucket. Set to 0 to remove quota.</div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-warning\"><i class=\"fas fa-save me-1\"></i>Update Quota</button></div></form></div></div></div><!-- JavaScript for bucket management --><script>\n document.addEventListener('DOMContentLoaded', function() {\n const quotaCheckbox = document.getElementById('enableQuota');\n const quotaSettings = document.getElementById('quotaSettings');\n const versioningCheckbox = document.getElementById('enableVersioning');\n const objectLockCheckbox = document.getElementById('enableObjectLock');\n const objectLockSettings = document.getElementById('objectLockSettings');\n const createBucketForm = document.getElementById('createBucketForm');\n\n // Toggle quota settings\n quotaCheckbox.addEventListener('change', function() {\n quotaSettings.style.display = this.checked ? 'block' : 'none';\n });\n\n // Toggle object lock settings and automatically enable versioning\n objectLockCheckbox.addEventListener('change', function() {\n objectLockSettings.style.display = this.checked ? 'block' : 'none';\n if (this.checked) {\n versioningCheckbox.checked = true;\n versioningCheckbox.disabled = true;\n } else {\n versioningCheckbox.disabled = false;\n }\n });\n\n // Handle form submission\n createBucketForm.addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const data = {\n name: formData.get('name'),\n region: formData.get('region') || '',\n quota_size: quotaCheckbox.checked ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: quotaCheckbox.checked,\n versioning_enabled: versioningCheckbox.checked,\n object_lock_enabled: objectLockCheckbox.checked,\n object_lock_mode: formData.get('object_lock_mode') || 'GOVERNANCE',\n object_lock_duration: objectLockCheckbox.checked ? parseInt(formData.get('object_lock_duration')) || 30 : 0\n };\n\n // Validate object lock settings\n if (data.object_lock_enabled && data.object_lock_duration <= 0) {\n alert('Please enter a valid retention duration for object lock.');\n return;\n }\n\n fetch('/api/s3/buckets', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error creating bucket: ' + data.error);\n } else {\n alert('Bucket created successfully!');\n location.reload();\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error creating bucket: ' + error.message);\n });\n });\n\n // Handle delete bucket\n document.querySelectorAll('.delete-bucket-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n document.getElementById('deleteBucketName').textContent = bucketName;\n window.currentBucketToDelete = bucketName;\n new bootstrap.Modal(document.getElementById('deleteBucketModal')).show();\n });\n });\n\n // Handle quota management\n document.querySelectorAll('.quota-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n const currentQuota = parseInt(this.dataset.currentQuota);\n const quotaEnabled = this.dataset.quotaEnabled === 'true';\n \n document.getElementById('quotaBucketName').value = bucketName;\n document.getElementById('quotaEnabled').checked = quotaEnabled;\n document.getElementById('quotaSizeMB').value = currentQuota;\n \n // Toggle quota size settings\n document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';\n \n window.currentBucketToUpdate = bucketName;\n new bootstrap.Modal(document.getElementById('manageQuotaModal')).show();\n });\n });\n\n // Handle quota form submission\n document.getElementById('quotaForm').addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const enabled = document.getElementById('quotaEnabled').checked;\n const data = {\n quota_size: enabled ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: enabled\n };\n\n fetch(`/api/s3/buckets/${window.currentBucketToUpdate}/quota`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error updating quota: ' + data.error);\n } else {\n alert('Quota updated successfully!');\n location.reload();\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error updating quota: ' + error.message);\n });\n });\n\n // Handle quota enabled checkbox\n document.getElementById('quotaEnabled').addEventListener('change', function() {\n document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';\n });\n });\n\n function deleteBucket() {\n const bucketName = window.currentBucketToDelete;\n if (!bucketName) return;\n\n fetch(`/api/s3/buckets/${bucketName}`, {\n method: 'DELETE'\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error deleting bucket: ' + data.error);\n } else {\n alert('Bucket deleted successfully!');\n location.reload();\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error deleting bucket: ' + error.message);\n });\n }\n\n function exportBucketList() {\n // Simple CSV export\n const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n name: cells[0].textContent.trim(),\n created: cells[1].textContent.trim(),\n objects: cells[2].textContent.trim(),\n size: cells[3].textContent.trim(),\n quota: cells[4].textContent.trim(),\n versioning: cells[5].textContent.trim(),\n objectLock: cells[6].textContent.trim()\n };\n }\n return null;\n }).filter(bucket => bucket !== null);\n\n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Name,Created,Objects,Size,Quota,Versioning,Object Lock\\n\" +\n buckets.map(b => `\"${b.name}\",\"${b.created}\",\"${b.objects}\",\"${b.size}\",\"${b.quota}\",\"${b.versioning}\",\"${b.objectLock}\"`).join(\"\\n\");\n\n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"buckets.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</small></div></div></div><!-- Create Bucket Modal --><div class=\"modal fade\" id=\"createBucketModal\" tabindex=\"-1\" aria-labelledby=\"createBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createBucketModalLabel\"><i class=\"fas fa-plus me-2\"></i>Create New S3 Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"createBucketForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label for=\"bucketName\" class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"bucketName\" name=\"name\" placeholder=\"my-bucket-name\" required pattern=\"[a-z0-9.-]+\" title=\"Bucket name must contain only lowercase letters, numbers, dots, and hyphens\"><div class=\"form-text\">Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableQuota\" name=\"quota_enabled\"> <label class=\"form-check-label\" for=\"enableQuota\">Enable Storage Quota</label></div></div><div class=\"mb-3\" id=\"quotaSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-8\"><label for=\"quotaSize\" class=\"form-label\">Quota Size</label> <input type=\"number\" class=\"form-control\" id=\"quotaSize\" name=\"quota_size\" placeholder=\"1024\" min=\"1\" step=\"1\"></div><div class=\"col-md-4\"><label for=\"quotaUnit\" class=\"form-label\">Unit</label> <select class=\"form-select\" id=\"quotaUnit\" name=\"quota_unit\"><option value=\"MB\" selected>MB</option> <option value=\"GB\">GB</option> <option value=\"TB\">TB</option></select></div></div><div class=\"form-text\">Set the maximum storage size for this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableVersioning\" name=\"versioning_enabled\"> <label class=\"form-check-label\" for=\"enableVersioning\">Enable Object Versioning</label></div><div class=\"form-text\">Keep multiple versions of objects in this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableObjectLock\" name=\"object_lock_enabled\"> <label class=\"form-check-label\" for=\"enableObjectLock\">Enable Object Lock</label></div><div class=\"form-text\">Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.</div></div><div class=\"mb-3\" id=\"objectLockSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-6\"><label for=\"objectLockMode\" class=\"form-label\">Object Lock Mode</label> <select class=\"form-select\" id=\"objectLockMode\" name=\"object_lock_mode\"><option value=\"GOVERNANCE\" selected>Governance</option> <option value=\"COMPLIANCE\">Compliance</option></select><div class=\"form-text\">Governance allows override with special permissions, Compliance is immutable.</div></div><div class=\"col-md-6\"><label for=\"objectLockDuration\" class=\"form-label\">Default Retention (days)</label> <input type=\"number\" class=\"form-control\" id=\"objectLockDuration\" name=\"object_lock_duration\" placeholder=\"30\" min=\"1\" max=\"36500\" step=\"1\"><div class=\"form-text\">Default retention period for new objects (1-36500 days).</div></div></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-plus me-1\"></i>Create Bucket</button></div></form></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteBucketModal\" tabindex=\"-1\" aria-labelledby=\"deleteBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"deleteBucketModalLabel\"><i class=\"fas fa-exclamation-triangle me-2 text-warning\"></i>Delete Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the bucket <strong id=\"deleteBucketName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-exclamation-triangle me-2\"></i> <strong>Warning:</strong> This action cannot be undone. All objects in the bucket will be permanently deleted.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" onclick=\"deleteBucket()\"><i class=\"fas fa-trash me-1\"></i>Delete Bucket</button></div></div></div></div><!-- Manage Quota Modal --><div class=\"modal fade\" id=\"manageQuotaModal\" tabindex=\"-1\" aria-labelledby=\"manageQuotaModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"manageQuotaModalLabel\"><i class=\"fas fa-tachometer-alt me-2\"></i>Manage Bucket Quota</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"quotaForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"quotaBucketName\" readonly></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"quotaEnabled\" name=\"quota_enabled\"> <label class=\"form-check-label\" for=\"quotaEnabled\">Enable Storage Quota</label></div></div><div class=\"mb-3\" id=\"quotaSizeSettings\"><div class=\"row\"><div class=\"col-md-8\"><label for=\"quotaSizeMB\" class=\"form-label\">Quota Size</label> <input type=\"number\" class=\"form-control\" id=\"quotaSizeMB\" name=\"quota_size\" placeholder=\"1024\" min=\"0\" step=\"1\"></div><div class=\"col-md-4\"><label for=\"quotaUnitMB\" class=\"form-label\">Unit</label> <select class=\"form-select\" id=\"quotaUnitMB\" name=\"quota_unit\"><option value=\"MB\" selected>MB</option> <option value=\"GB\">GB</option> <option value=\"TB\">TB</option></select></div></div><div class=\"form-text\">Set the maximum storage size for this bucket. Set to 0 to remove quota.</div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-warning\"><i class=\"fas fa-save me-1\"></i>Update Quota</button></div></form></div></div></div><!-- Bucket Details Modal --><div class=\"modal fade\" id=\"bucketDetailsModal\" tabindex=\"-1\" aria-labelledby=\"bucketDetailsModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"bucketDetailsModalLabel\"><i class=\"fas fa-cube me-2\"></i>Bucket Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><div id=\"bucketDetailsContent\"><div class=\"text-center py-4\"><div class=\"spinner-border text-primary\" role=\"status\"><span class=\"visually-hidden\">Loading...</span></div><div class=\"mt-2\">Loading bucket details...</div></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- JavaScript for bucket management --><script>\n document.addEventListener('DOMContentLoaded', function() {\n const quotaCheckbox = document.getElementById('enableQuota');\n const quotaSettings = document.getElementById('quotaSettings');\n const versioningCheckbox = document.getElementById('enableVersioning');\n const objectLockCheckbox = document.getElementById('enableObjectLock');\n const objectLockSettings = document.getElementById('objectLockSettings');\n const createBucketForm = document.getElementById('createBucketForm');\n\n // Toggle quota settings\n quotaCheckbox.addEventListener('change', function() {\n quotaSettings.style.display = this.checked ? 'block' : 'none';\n });\n\n // Toggle object lock settings and automatically enable versioning\n objectLockCheckbox.addEventListener('change', function() {\n objectLockSettings.style.display = this.checked ? 'block' : 'none';\n if (this.checked) {\n versioningCheckbox.checked = true;\n versioningCheckbox.disabled = true;\n } else {\n versioningCheckbox.disabled = false;\n }\n });\n\n // Handle form submission\n createBucketForm.addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const data = {\n name: formData.get('name'),\n region: formData.get('region') || '',\n quota_size: quotaCheckbox.checked ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: quotaCheckbox.checked,\n versioning_enabled: versioningCheckbox.checked,\n object_lock_enabled: objectLockCheckbox.checked,\n object_lock_mode: formData.get('object_lock_mode') || 'GOVERNANCE',\n object_lock_duration: objectLockCheckbox.checked ? parseInt(formData.get('object_lock_duration')) || 30 : 0\n };\n\n // Validate object lock settings\n if (data.object_lock_enabled && data.object_lock_duration <= 0) {\n alert('Please enter a valid retention duration for object lock.');\n return;\n }\n\n fetch('/api/s3/buckets', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error creating bucket: ' + data.error);\n } else {\n alert('Bucket created successfully!');\n // Properly close the modal before reloading\n const createModal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal'));\n if (createModal) {\n createModal.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error creating bucket: ' + error.message);\n });\n });\n\n // Handle delete bucket\n let deleteModalInstance = null;\n document.querySelectorAll('.delete-bucket-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n document.getElementById('deleteBucketName').textContent = bucketName;\n window.currentBucketToDelete = bucketName;\n \n // Dispose of existing modal instance if it exists\n if (deleteModalInstance) {\n deleteModalInstance.dispose();\n }\n \n // Create new modal instance\n deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));\n deleteModalInstance.show();\n });\n });\n\n // Add event listener to properly dispose of delete modal when hidden\n document.getElementById('deleteBucketModal').addEventListener('hidden.bs.modal', function() {\n if (deleteModalInstance) {\n deleteModalInstance.dispose();\n deleteModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n\n // Handle quota management\n let quotaModalInstance = null;\n document.querySelectorAll('.quota-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n const currentQuota = parseInt(this.dataset.currentQuota);\n const quotaEnabled = this.dataset.quotaEnabled === 'true';\n \n document.getElementById('quotaBucketName').value = bucketName;\n document.getElementById('quotaEnabled').checked = quotaEnabled;\n document.getElementById('quotaSizeMB').value = currentQuota;\n \n // Toggle quota size settings\n document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';\n \n window.currentBucketToUpdate = bucketName;\n \n // Dispose of existing modal instance if it exists\n if (quotaModalInstance) {\n quotaModalInstance.dispose();\n }\n \n // Create new modal instance\n quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));\n quotaModalInstance.show();\n });\n });\n\n // Add event listener to properly dispose of quota modal when hidden\n document.getElementById('manageQuotaModal').addEventListener('hidden.bs.modal', function() {\n if (quotaModalInstance) {\n quotaModalInstance.dispose();\n quotaModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n\n // Handle quota form submission\n document.getElementById('quotaForm').addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const enabled = document.getElementById('quotaEnabled').checked;\n const data = {\n quota_size: enabled ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: enabled\n };\n\n fetch(`/api/s3/buckets/${window.currentBucketToUpdate}/quota`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error updating quota: ' + data.error);\n } else {\n alert('Quota updated successfully!');\n // Properly close the modal before reloading\n if (quotaModalInstance) {\n quotaModalInstance.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error updating quota: ' + error.message);\n });\n });\n\n // Handle quota enabled checkbox\n document.getElementById('quotaEnabled').addEventListener('change', function() {\n document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';\n });\n\n // Handle view details button\n let detailsModalInstance = null;\n document.querySelectorAll('.view-details-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n \n // Update modal title\n document.getElementById('bucketDetailsModalLabel').innerHTML = \n '<i class=\"fas fa-cube me-2\"></i>Bucket Details - ' + bucketName;\n \n // Show loading spinner\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"text-center py-4\">' +\n '<div class=\"spinner-border text-primary\" role=\"status\">' +\n '<span class=\"visually-hidden\">Loading...</span>' +\n '</div>' +\n '<div class=\"mt-2\">Loading bucket details...</div>' +\n '</div>';\n \n // Dispose of existing modal instance if it exists\n if (detailsModalInstance) {\n detailsModalInstance.dispose();\n }\n \n // Create new modal instance\n detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));\n detailsModalInstance.show();\n \n // Fetch bucket details\n fetch('/api/s3/buckets/' + bucketName)\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"alert alert-danger\">' +\n '<i class=\"fas fa-exclamation-triangle me-2\"></i>' +\n 'Error loading bucket details: ' + data.error +\n '</div>';\n } else {\n displayBucketDetails(data);\n }\n })\n .catch(error => {\n console.error('Error fetching bucket details:', error);\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"alert alert-danger\">' +\n '<i class=\"fas fa-exclamation-triangle me-2\"></i>' +\n 'Error loading bucket details: ' + error.message +\n '</div>';\n });\n });\n });\n\n // Add event listener to properly dispose of details modal when hidden\n document.getElementById('bucketDetailsModal').addEventListener('hidden.bs.modal', function() {\n if (detailsModalInstance) {\n detailsModalInstance.dispose();\n detailsModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n });\n\n function deleteBucket() {\n const bucketName = window.currentBucketToDelete;\n if (!bucketName) return;\n\n fetch(`/api/s3/buckets/${bucketName}`, {\n method: 'DELETE'\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error deleting bucket: ' + data.error);\n } else {\n alert('Bucket deleted successfully!');\n // Properly close the modal before reloading\n if (deleteModalInstance) {\n deleteModalInstance.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error deleting bucket: ' + error.message);\n });\n }\n\n function displayBucketDetails(data) {\n const bucket = data.bucket;\n const objects = data.objects || [];\n \n // Helper function to format bytes\n function formatBytes(bytes) {\n if (bytes === 0) return '0 Bytes';\n const k = 1024;\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n }\n \n // Helper function to format date\n function formatDate(dateString) {\n const date = new Date(dateString);\n return date.toLocaleString();\n }\n \n // Generate objects table\n let objectsTable = '';\n if (objects.length > 0) {\n objectsTable = '<div class=\"table-responsive\">' +\n '<table class=\"table table-sm table-striped\">' +\n '<thead>' +\n '<tr>' +\n '<th>Object Key</th>' +\n '<th>Size</th>' +\n '<th>Last Modified</th>' +\n '<th>Storage Class</th>' +\n '</tr>' +\n '</thead>' +\n '<tbody>' +\n objects.map(obj => \n '<tr>' +\n '<td><i class=\"fas fa-file me-1\"></i>' + obj.key + '</td>' +\n '<td>' + formatBytes(obj.size) + '</td>' +\n '<td>' + formatDate(obj.last_modified) + '</td>' +\n '<td><span class=\"badge bg-primary\">' + obj.storage_class + '</span></td>' +\n '</tr>'\n ).join('') +\n '</tbody>' +\n '</table>' +\n '</div>';\n } else {\n objectsTable = '<div class=\"text-center py-4 text-muted\">' +\n '<i class=\"fas fa-file fa-3x mb-3\"></i>' +\n '<div>No objects found in this bucket</div>' +\n '</div>';\n }\n \n const content = '<div class=\"row\">' +\n '<div class=\"col-md-6\">' +\n '<h6><i class=\"fas fa-info-circle me-2\"></i>Bucket Information</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr>' +\n '<td><strong>Name:</strong></td>' +\n '<td>' + bucket.name + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Created:</strong></td>' +\n '<td>' + formatDate(bucket.created_at) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Last Modified:</strong></td>' +\n '<td>' + formatDate(bucket.last_modified) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Total Size:</strong></td>' +\n '<td>' + formatBytes(bucket.size) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Object Count:</strong></td>' +\n '<td>' + bucket.object_count + '</td>' +\n '</tr>' +\n '</table>' +\n '</div>' +\n '<div class=\"col-md-6\">' +\n '<h6><i class=\"fas fa-cogs me-2\"></i>Configuration</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr>' +\n '<td><strong>Quota:</strong></td>' +\n '<td>' +\n (bucket.quota_enabled ? \n '<span class=\"badge bg-success\">' + formatBytes(bucket.quota) + '</span>' : \n '<span class=\"badge bg-secondary\">Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Versioning:</strong></td>' +\n '<td>' +\n (bucket.versioning_enabled ? \n '<span class=\"badge bg-success\"><i class=\"fas fa-check me-1\"></i>Enabled</span>' : \n '<span class=\"badge bg-secondary\"><i class=\"fas fa-times me-1\"></i>Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Object Lock:</strong></td>' +\n '<td>' +\n (bucket.object_lock_enabled ? \n '<span class=\"badge bg-warning\"><i class=\"fas fa-lock me-1\"></i>Enabled</span>' +\n '<br><small class=\"text-muted\">' + bucket.object_lock_mode + ' • ' + bucket.object_lock_duration + ' days</small>' : \n '<span class=\"badge bg-secondary\"><i class=\"fas fa-unlock me-1\"></i>Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '</table>' +\n '</div>' +\n '</div>' +\n '<hr>' +\n '<div class=\"row\">' +\n '<div class=\"col-12\">' +\n '<h6><i class=\"fas fa-list me-2\"></i>Objects (' + objects.length + ')</h6>' +\n objectsTable +\n '</div>' +\n '</div>';\n \n document.getElementById('bucketDetailsContent').innerHTML = content;\n }\n\n function exportBucketList() {\n // Simple CSV export\n const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n name: cells[0].textContent.trim(),\n created: cells[1].textContent.trim(),\n objects: cells[2].textContent.trim(),\n size: cells[3].textContent.trim(),\n quota: cells[4].textContent.trim(),\n versioning: cells[5].textContent.trim(),\n objectLock: cells[6].textContent.trim()\n };\n }\n return null;\n }).filter(bucket => bucket !== null);\n\n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Name,Created,Objects,Size,Quota,Versioning,Object Lock\\n\" +\n buckets.map(b => '\"' + b.name + '\",\"' + b.created + '\",\"' + b.objects + '\",\"' + b.size + '\",\"' + b.quota + '\",\"' + b.versioning + '\",\"' + b.objectLock + '\"').join(\"\\n\");\n\n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"buckets.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/layout/layout.templ b/weed/admin/view/layout/layout.templ
index 2261f1e41..b5e2cefbf 100644
--- a/weed/admin/view/layout/layout.templ
+++ b/weed/admin/view/layout/layout.templ
@@ -147,6 +147,11 @@ templ Layout(c *gin.Context, content templ.Component) {
<i class="fas fa-users me-2"></i>Users
</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link py-2" href="/object-store/policies">
+ <i class="fas fa-shield-alt me-2"></i>Policies
+ </a>
+ </li>
</ul>
</div>
</li>
diff --git a/weed/admin/view/layout/layout_templ.go b/weed/admin/view/layout/layout_templ.go
index c321c7a6b..562faa677 100644
--- a/weed/admin/view/layout/layout_templ.go
+++ b/weed/admin/view/layout/layout_templ.go
@@ -62,7 +62,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</a><ul class=\"dropdown-menu\"><li><a class=\"dropdown-item\" href=\"/logout\"><i class=\"fas fa-sign-out-alt me-2\"></i>Logout</a></li></ul></li></ul></div></div></header><div class=\"row g-0\"><!-- Sidebar --><div class=\"col-md-3 col-lg-2 d-md-block bg-light sidebar collapse\"><div class=\"position-sticky pt-3\"><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MAIN</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/admin\"><i class=\"fas fa-tachometer-alt me-2\"></i>Dashboard</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#clusterSubmenu\" aria-expanded=\"false\" aria-controls=\"clusterSubmenu\"><i class=\"fas fa-sitemap me-2\"></i>Cluster <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"clusterSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/masters\"><i class=\"fas fa-crown me-2\"></i>Masters</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volume-servers\"><i class=\"fas fa-server me-2\"></i>Volume Servers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/filers\"><i class=\"fas fa-folder-open me-2\"></i>Filers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volumes\"><i class=\"fas fa-database me-2\"></i>Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/collections\"><i class=\"fas fa-layer-group me-2\"></i>Collections</a></li></ul></div></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MANAGEMENT</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/files\"><i class=\"fas fa-folder me-2\"></i>File Browser</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#objectStoreSubmenu\" aria-expanded=\"false\" aria-controls=\"objectStoreSubmenu\"><i class=\"fas fa-cloud me-2\"></i>Object Store <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"objectStoreSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/buckets\"><i class=\"fas fa-cube me-2\"></i>Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/users\"><i class=\"fas fa-users me-2\"></i>Users</a></li></ul></div></li><li class=\"nav-item\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</a><ul class=\"dropdown-menu\"><li><a class=\"dropdown-item\" href=\"/logout\"><i class=\"fas fa-sign-out-alt me-2\"></i>Logout</a></li></ul></li></ul></div></div></header><div class=\"row g-0\"><!-- Sidebar --><div class=\"col-md-3 col-lg-2 d-md-block bg-light sidebar collapse\"><div class=\"position-sticky pt-3\"><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MAIN</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/admin\"><i class=\"fas fa-tachometer-alt me-2\"></i>Dashboard</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#clusterSubmenu\" aria-expanded=\"false\" aria-controls=\"clusterSubmenu\"><i class=\"fas fa-sitemap me-2\"></i>Cluster <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"clusterSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/masters\"><i class=\"fas fa-crown me-2\"></i>Masters</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volume-servers\"><i class=\"fas fa-server me-2\"></i>Volume Servers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/filers\"><i class=\"fas fa-folder-open me-2\"></i>Filers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volumes\"><i class=\"fas fa-database me-2\"></i>Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/collections\"><i class=\"fas fa-layer-group me-2\"></i>Collections</a></li></ul></div></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MANAGEMENT</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/files\"><i class=\"fas fa-folder me-2\"></i>File Browser</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#objectStoreSubmenu\" aria-expanded=\"false\" aria-controls=\"objectStoreSubmenu\"><i class=\"fas fa-cloud me-2\"></i>Object Store <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"objectStoreSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/buckets\"><i class=\"fas fa-cube me-2\"></i>Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/users\"><i class=\"fas fa-users me-2\"></i>Users</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/policies\"><i class=\"fas fa-shield-alt me-2\"></i>Policies</a></li></ul></div></li><li class=\"nav-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -153,7 +153,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 248, Col: 117}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 253, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -188,7 +188,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 249, Col: 109}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 254, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -206,7 +206,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var7 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 252, Col: 110}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 257, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -241,7 +241,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 253, Col: 109}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 258, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -274,7 +274,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var11 templ.SafeURL
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 265, Col: 106}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 270, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -309,7 +309,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 266, Col: 105}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 271, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -370,7 +370,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year()))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 313, Col: 60}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 318, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -383,7 +383,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 313, Col: 102}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 318, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -435,7 +435,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 337, Col: 17}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 342, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -448,7 +448,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 351, Col: 57}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 356, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -466,7 +466,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 358, Col: 45}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 363, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {