Browse Source

Admin UI: Add policies (#6968)

* add policies to UI, accessing filer directly

* view, edit policies

* add back buttons for "users" page

* remove unused

* fix ui dark mode when modal is closed

* bucket view details button

* fix browser buttons

* filer action button works

* clean up masters page

* fix volume servers action buttons

* fix collections page action button

* fix properties page

* more obvious

* fix directory creation file mode

* Update file_browser_handlers.go

* directory permission
pull/6973/head
Chris Lu 3 months ago
committed by GitHub
parent
commit
687a6a6c1d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      weed/admin/dash/admin_server.go
  2. 80
      weed/admin/dash/file_browser_data.go
  3. 85
      weed/admin/dash/file_mode_utils.go
  4. 225
      weed/admin/dash/policies_management.go
  5. 27
      weed/admin/handlers/admin_handlers.go
  6. 15
      weed/admin/handlers/file_browser_handlers.go
  7. 233
      weed/admin/handlers/maintenance_handlers.go
  8. 273
      weed/admin/handlers/policy_handlers.go
  9. 215
      weed/admin/view/app/cluster_collections.templ
  10. 87
      weed/admin/view/app/cluster_collections_templ.go
  11. 56
      weed/admin/view/app/cluster_filers.templ
  12. 2
      weed/admin/view/app/cluster_filers_templ.go
  13. 123
      weed/admin/view/app/cluster_masters.templ
  14. 57
      weed/admin/view/app/cluster_masters_templ.go
  15. 181
      weed/admin/view/app/cluster_volume_servers.templ
  16. 148
      weed/admin/view/app/cluster_volume_servers_templ.go
  17. 376
      weed/admin/view/app/file_browser.templ
  18. 98
      weed/admin/view/app/file_browser_templ.go
  19. 350
      weed/admin/view/app/object_store_users.templ
  20. 2
      weed/admin/view/app/object_store_users_templ.go
  21. 658
      weed/admin/view/app/policies.templ
  22. 204
      weed/admin/view/app/policies_templ.go
  23. 299
      weed/admin/view/app/s3_buckets.templ
  24. 22
      weed/admin/view/app/s3_buckets_templ.go
  25. 5
      weed/admin/view/layout/layout.templ
  26. 24
      weed/admin/view/layout/layout_templ.go
  27. 22
      weed/credential/credential_store.go
  28. 188
      weed/credential/filer_etc/filer_etc_identity.go
  29. 114
      weed/credential/filer_etc/filer_etc_policy.go
  30. 180
      weed/credential/filer_etc/filer_etc_store.go
  31. 302
      weed/credential/memory/memory_identity.go
  32. 77
      weed/credential/memory/memory_policy.go
  33. 303
      weed/credential/memory/memory_store.go
  34. 446
      weed/credential/postgres/postgres_identity.go
  35. 130
      weed/credential/postgres/postgres_policy.go
  36. 449
      weed/credential/postgres/postgres_store.go
  37. 146
      weed/credential/test/policy_test.go
  38. 369
      weed/worker/tasks/balance/ui_templ.go
  39. 319
      weed/worker/tasks/erasure_coding/ui_templ.go
  40. 330
      weed/worker/tasks/vacuum/ui_templ.go
  41. 63
      weed/worker/types/task_ui_templ.go

1
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) glog.V(1).Infof("Set filer client for credential manager: %s", filerAddr)
break break
} }
glog.V(1).Infof("Waiting for filer discovery for credential manager...")
time.Sleep(5 * time.Second) // Retry every 5 seconds time.Sleep(5 * time.Second) // Retry every 5 seconds
} }
}() }()

80
weed/admin/dash/file_browser_data.go

@ -99,7 +99,7 @@ func (s *AdminServer) GetFileBrowser(path string) (*FileBrowserData, error) {
var ttlSec int32 var ttlSec int32
if entry.Attributes != nil { if entry.Attributes != nil {
mode = formatFileMode(entry.Attributes.FileMode)
mode = FormatFileMode(entry.Attributes.FileMode)
uid = entry.Attributes.Uid uid = entry.Attributes.Uid
gid = entry.Attributes.Gid gid = entry.Attributes.Gid
size = int64(entry.Attributes.FileSize) size = int64(entry.Attributes.FileSize)
@ -270,81 +270,3 @@ func (s *AdminServer) generateBreadcrumbs(path string) []BreadcrumbItem {
return breadcrumbs 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)
}

85
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)
}

225
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
}

27
weed/admin/handlers/admin_handlers.go

@ -17,6 +17,7 @@ type AdminHandlers struct {
clusterHandlers *ClusterHandlers clusterHandlers *ClusterHandlers
fileBrowserHandlers *FileBrowserHandlers fileBrowserHandlers *FileBrowserHandlers
userHandlers *UserHandlers userHandlers *UserHandlers
policyHandlers *PolicyHandlers
maintenanceHandlers *MaintenanceHandlers maintenanceHandlers *MaintenanceHandlers
mqHandlers *MessageQueueHandlers mqHandlers *MessageQueueHandlers
} }
@ -27,6 +28,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
clusterHandlers := NewClusterHandlers(adminServer) clusterHandlers := NewClusterHandlers(adminServer)
fileBrowserHandlers := NewFileBrowserHandlers(adminServer) fileBrowserHandlers := NewFileBrowserHandlers(adminServer)
userHandlers := NewUserHandlers(adminServer) userHandlers := NewUserHandlers(adminServer)
policyHandlers := NewPolicyHandlers(adminServer)
maintenanceHandlers := NewMaintenanceHandlers(adminServer) maintenanceHandlers := NewMaintenanceHandlers(adminServer)
mqHandlers := NewMessageQueueHandlers(adminServer) mqHandlers := NewMessageQueueHandlers(adminServer)
return &AdminHandlers{ return &AdminHandlers{
@ -35,6 +37,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
clusterHandlers: clusterHandlers, clusterHandlers: clusterHandlers,
fileBrowserHandlers: fileBrowserHandlers, fileBrowserHandlers: fileBrowserHandlers,
userHandlers: userHandlers, userHandlers: userHandlers,
policyHandlers: policyHandlers,
maintenanceHandlers: maintenanceHandlers, maintenanceHandlers: maintenanceHandlers,
mqHandlers: mqHandlers, 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", h.ShowS3Buckets)
protected.GET("/object-store/buckets/:bucket", h.ShowBucketDetails) protected.GET("/object-store/buckets/:bucket", h.ShowBucketDetails)
protected.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers) protected.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
protected.GET("/object-store/policies", h.policyHandlers.ShowPolicies)
// File browser routes // File browser routes
protected.GET("/files", h.fileBrowserHandlers.ShowFileBrowser) 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) 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 // File management API routes
filesApi := api.Group("/files") 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", h.ShowS3Buckets)
r.GET("/object-store/buckets/:bucket", h.ShowBucketDetails) r.GET("/object-store/buckets/:bucket", h.ShowBucketDetails)
r.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers) r.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
r.GET("/object-store/policies", h.policyHandlers.ShowPolicies)
// File browser routes // File browser routes
r.GET("/files", h.fileBrowserHandlers.ShowFileBrowser) 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) 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 // File management API routes
filesApi := api.Group("/files") filesApi := api.Group("/files")
{ {

15
weed/admin/handlers/file_browser_handlers.go

@ -8,6 +8,7 @@ import (
"mime/multipart" "mime/multipart"
"net" "net"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@ -190,7 +191,7 @@ func (h *FileBrowserHandlers) CreateFolder(c *gin.Context) {
Name: filepath.Base(fullPath), Name: filepath.Base(fullPath),
IsDirectory: true, IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{ Attributes: &filer_pb.FuseAttributes{
FileMode: uint32(0755 | (1 << 31)), // Directory mode
FileMode: uint32(0755 | os.ModeDir), // Directory mode
Uid: filer_pb.OS_UID, Uid: filer_pb.OS_UID,
Gid: filer_pb.OS_GID, Gid: filer_pb.OS_GID,
Crtime: time.Now().Unix(), Crtime: time.Now().Unix(),
@ -656,8 +657,9 @@ func (h *FileBrowserHandlers) GetFileProperties(c *gin.Context) {
properties["created_timestamp"] = entry.Attributes.Crtime 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["uid"] = entry.Attributes.Uid
properties["gid"] = entry.Attributes.Gid properties["gid"] = entry.Attributes.Gid
properties["ttl_seconds"] = entry.Attributes.TtlSec 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]) 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 // Helper function to determine MIME type from filename
func (h *FileBrowserHandlers) determineMimeType(filename string) string { func (h *FileBrowserHandlers) determineMimeType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename)) ext := strings.ToLower(filepath.Ext(filename))

233
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/components"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks" "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" "github.com/seaweedfs/seaweedfs/weed/worker/types"
) )
@ -114,59 +111,60 @@ func (h *MaintenanceHandlers) ShowTaskConfig(c *gin.Context) {
return 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 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 // Create task configuration data using templ components
configData := &app.TaskConfigTemplData{ configData := &app.TaskConfigTemplData{
@ -199,8 +197,8 @@ func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) {
return 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 // Parse form data
err := c.Request.ParseForm() err := c.Request.ParseForm()
@ -217,52 +215,53 @@ func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) {
var config interface{} 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 // Redirect back to task configuration page
c.Redirect(http.StatusSeeOther, "/maintenance/config/"+taskTypeName) c.Redirect(http.StatusSeeOther, "/maintenance/config/"+taskTypeName)
@ -350,39 +349,35 @@ func floatPtr(f float64) *float64 {
return &f 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() { 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 // 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 return nil
} }

273
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(),
}
}

215
weed/admin/view/app/cluster_collections.templ

@ -164,22 +164,18 @@ templ ClusterCollections(data dash.ClusterCollectionsData) {
} }
</td> </td>
<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> </td>
</tr> </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 { func getDiskTypeColor(diskType string) string {

87
weed/admin/view/app/cluster_collections_templ.go
File diff suppressed because it is too large
View File

56
weed/admin/view/app/cluster_filers.templ

@ -121,6 +121,62 @@ templ ClusterFilers(data dash.ClusterFilersData) {
</div> </div>
</div> </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>
} }

2
weed/admin/view/app/cluster_filers_templ.go

@ -183,7 +183,7 @@ func ClusterFilers(data dash.ClusterFilersData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

123
weed/admin/view/app/cluster_masters.templ

@ -136,14 +136,15 @@ templ ClusterMasters(data dash.ClusterMastersData) {
} }
</td> </td>
<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> </td>
</tr> </tr>
} }
@ -170,6 +171,112 @@ templ ClusterMasters(data dash.ClusterMastersData) {
</div> </div>
</div> </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>
} }

57
weed/admin/view/app/cluster_masters_templ.go

@ -154,35 +154,74 @@ func ClusterMasters(data dash.ClusterMastersData) templ.Component {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

181
weed/admin/view/app/cluster_volume_servers.templ

@ -148,16 +148,22 @@ templ ClusterVolumeServers(data dash.ClusterVolumeServersData) {
</div> </div>
</td> </td>
<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> </td>
</tr> </tr>
} }
@ -184,6 +190,161 @@ templ ClusterVolumeServers(data dash.ClusterVolumeServersData) {
</div> </div>
</div> </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>
} }

148
weed/admin/view/app/cluster_volume_servers_templ.go
File diff suppressed because it is too large
View File

376
weed/admin/view/app/file_browser.templ

@ -228,7 +228,7 @@ templ FileBrowser(data dash.FileBrowserData) {
} }
</td> </td>
<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>
<td> <td>
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
@ -356,6 +356,380 @@ templ FileBrowser(data dash.FileBrowserData) {
</div> </div>
</div> </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 { func countDirectories(entries []dash.FileEntry) int {

98
weed/admin/view/app/file_browser_templ.go
File diff suppressed because it is too large
View File

350
weed/admin/view/app/object_store_users.templ

@ -317,7 +317,355 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
<!-- JavaScript for user management --> <!-- JavaScript for user management -->
<script> <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> </script>
} }

2
weed/admin/view/app/object_store_users_templ.go
File diff suppressed because it is too large
View File

658
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>
}

204
weed/admin/view/app/policies_templ.go
File diff suppressed because it is too large
View File

299
weed/admin/view/app/s3_buckets.templ

@ -187,11 +187,12 @@ templ S3Buckets(data dash.S3BucketsData) {
title="Browse Files"> title="Browse Files">
<i class="fas fa-folder-open"></i> <i class="fas fa-folder-open"></i>
</a> </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> <i class="fas fa-eye"></i>
</a>
</button>
<button type="button" <button type="button"
class="btn btn-outline-warning btn-sm quota-btn" class="btn btn-outline-warning btn-sm quota-btn"
data-bucket-name={bucket.Name} data-bucket-name={bucket.Name}
@ -442,6 +443,33 @@ templ S3Buckets(data dash.S3BucketsData) {
</div> </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 --> <!-- JavaScript for bucket management -->
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@ -504,7 +532,12 @@ templ S3Buckets(data dash.S3BucketsData) {
alert('Error creating bucket: ' + data.error); alert('Error creating bucket: ' + data.error);
} else { } else {
alert('Bucket created successfully!'); 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 => { .catch(error => {
@ -514,16 +547,41 @@ templ S3Buckets(data dash.S3BucketsData) {
}); });
// Handle delete bucket // Handle delete bucket
let deleteModalInstance = null;
document.querySelectorAll('.delete-bucket-btn').forEach(button => { document.querySelectorAll('.delete-bucket-btn').forEach(button => {
button.addEventListener('click', function() { button.addEventListener('click', function() {
const bucketName = this.dataset.bucketName; const bucketName = this.dataset.bucketName;
document.getElementById('deleteBucketName').textContent = bucketName; document.getElementById('deleteBucketName').textContent = bucketName;
window.currentBucketToDelete = 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 // Handle quota management
let quotaModalInstance = null;
document.querySelectorAll('.quota-btn').forEach(button => { document.querySelectorAll('.quota-btn').forEach(button => {
button.addEventListener('click', function() { button.addEventListener('click', function() {
const bucketName = this.dataset.bucketName; const bucketName = this.dataset.bucketName;
@ -538,8 +596,31 @@ templ S3Buckets(data dash.S3BucketsData) {
document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none'; document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';
window.currentBucketToUpdate = bucketName; 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 // Handle quota form submission
@ -567,7 +648,11 @@ templ S3Buckets(data dash.S3BucketsData) {
alert('Error updating quota: ' + data.error); alert('Error updating quota: ' + data.error);
} else { } else {
alert('Quota updated successfully!'); alert('Quota updated successfully!');
location.reload();
// Properly close the modal before reloading
if (quotaModalInstance) {
quotaModalInstance.hide();
}
setTimeout(() => location.reload(), 500);
} }
}) })
.catch(error => { .catch(error => {
@ -580,6 +665,74 @@ templ S3Buckets(data dash.S3BucketsData) {
document.getElementById('quotaEnabled').addEventListener('change', function() { document.getElementById('quotaEnabled').addEventListener('change', function() {
document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none'; 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() { function deleteBucket() {
@ -595,7 +748,11 @@ templ S3Buckets(data dash.S3BucketsData) {
alert('Error deleting bucket: ' + data.error); alert('Error deleting bucket: ' + data.error);
} else { } else {
alert('Bucket deleted successfully!'); alert('Bucket deleted successfully!');
location.reload();
// Properly close the modal before reloading
if (deleteModalInstance) {
deleteModalInstance.hide();
}
setTimeout(() => location.reload(), 500);
} }
}) })
.catch(error => { .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() { function exportBucketList() {
// Simple CSV export // Simple CSV export
const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => { 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," + const csvContent = "data:text/csv;charset=utf-8," +
"Name,Created,Objects,Size,Quota,Versioning,Object Lock\n" + "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 encodedUri = encodeURI(csvContent);
const link = document.createElement("a"); const link = document.createElement("a");

22
weed/admin/view/app/s3_buckets_templ.go
File diff suppressed because it is too large
View File

5
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 <i class="fas fa-users me-2"></i>Users
</a> </a>
</li> </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> </ul>
</div> </div>
</li> </li>

24
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -153,7 +153,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var3 templ.SafeURL var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL)) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -188,7 +188,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { 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 var templ_7745c5c3_Var7 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL)) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -241,7 +241,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { 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 var templ_7745c5c3_Var11 templ.SafeURL
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL)) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -309,7 +309,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -370,7 +370,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year())) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year()))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -383,7 +383,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { 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 var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { 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 var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(title) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { 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 var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {

22
weed/credential/credential_store.go

@ -86,5 +86,27 @@ type UserCredentials struct {
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
// PolicyStatement represents a single policy statement in an IAM policy
type PolicyStatement struct {
Effect string `json:"Effect"`
Action []string `json:"Action"`
Resource []string `json:"Resource"`
}
// PolicyDocument represents an IAM policy document
type PolicyDocument struct {
Version string `json:"Version"`
Statement []*PolicyStatement `json:"Statement"`
}
// PolicyManager interface for managing IAM policies
type PolicyManager interface {
GetPolicies(ctx context.Context) (map[string]PolicyDocument, error)
CreatePolicy(ctx context.Context, name string, document PolicyDocument) error
UpdatePolicy(ctx context.Context, name string, document PolicyDocument) error
DeletePolicy(ctx context.Context, name string) error
GetPolicy(ctx context.Context, name string) (*PolicyDocument, error)
}
// Stores holds all available credential store implementations // Stores holds all available credential store implementations
var Stores []CredentialStore var Stores []CredentialStore

188
weed/credential/filer_etc/filer_etc_identity.go

@ -0,0 +1,188 @@
package filer_etc
import (
"bytes"
"context"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
func (store *FilerEtcStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
s3cfg := &iam_pb.S3ApiConfiguration{}
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
var buf bytes.Buffer
if err := filer.ReadEntry(nil, client, filer.IamConfigDirectory, filer.IamIdentityFile, &buf); err != nil {
if err != filer_pb.ErrNotFound {
return err
}
}
if buf.Len() > 0 {
return filer.ParseS3ConfigurationFromBytes(buf.Bytes(), s3cfg)
}
return nil
})
return s3cfg, err
}
func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
var buf bytes.Buffer
if err := filer.ProtoToText(&buf, config); err != nil {
return fmt.Errorf("failed to marshal configuration: %v", err)
}
return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamIdentityFile, buf.Bytes())
})
}
func (store *FilerEtcStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
// Load existing configuration
config, err := store.LoadConfiguration(ctx)
if err != nil {
return fmt.Errorf("failed to load configuration: %v", err)
}
// Check if user already exists
for _, existingIdentity := range config.Identities {
if existingIdentity.Name == identity.Name {
return credential.ErrUserAlreadyExists
}
}
// Add new identity
config.Identities = append(config.Identities, identity)
// Save configuration
return store.SaveConfiguration(ctx, config)
}
func (store *FilerEtcStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load configuration: %v", err)
}
for _, identity := range config.Identities {
if identity.Name == username {
return identity, nil
}
}
return nil, credential.ErrUserNotFound
}
func (store *FilerEtcStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return fmt.Errorf("failed to load configuration: %v", err)
}
// Find and update the user
for i, existingIdentity := range config.Identities {
if existingIdentity.Name == username {
config.Identities[i] = identity
return store.SaveConfiguration(ctx, config)
}
}
return credential.ErrUserNotFound
}
func (store *FilerEtcStore) DeleteUser(ctx context.Context, username string) error {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return fmt.Errorf("failed to load configuration: %v", err)
}
// Find and remove the user
for i, identity := range config.Identities {
if identity.Name == username {
config.Identities = append(config.Identities[:i], config.Identities[i+1:]...)
return store.SaveConfiguration(ctx, config)
}
}
return credential.ErrUserNotFound
}
func (store *FilerEtcStore) ListUsers(ctx context.Context) ([]string, error) {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load configuration: %v", err)
}
var usernames []string
for _, identity := range config.Identities {
usernames = append(usernames, identity.Name)
}
return usernames, nil
}
func (store *FilerEtcStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load configuration: %v", err)
}
for _, identity := range config.Identities {
for _, credential := range identity.Credentials {
if credential.AccessKey == accessKey {
return identity, nil
}
}
}
return nil, credential.ErrAccessKeyNotFound
}
func (store *FilerEtcStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return fmt.Errorf("failed to load configuration: %v", err)
}
// Find the user and add the credential
for _, identity := range config.Identities {
if identity.Name == username {
// Check if access key already exists
for _, existingCred := range identity.Credentials {
if existingCred.AccessKey == cred.AccessKey {
return fmt.Errorf("access key %s already exists", cred.AccessKey)
}
}
identity.Credentials = append(identity.Credentials, cred)
return store.SaveConfiguration(ctx, config)
}
}
return credential.ErrUserNotFound
}
func (store *FilerEtcStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return fmt.Errorf("failed to load configuration: %v", err)
}
// Find the user and remove the credential
for _, identity := range config.Identities {
if identity.Name == username {
for i, cred := range identity.Credentials {
if cred.AccessKey == accessKey {
identity.Credentials = append(identity.Credentials[:i], identity.Credentials[i+1:]...)
return store.SaveConfiguration(ctx, config)
}
}
return credential.ErrAccessKeyNotFound
}
}
return credential.ErrUserNotFound
}

114
weed/credential/filer_etc/filer_etc_policy.go

@ -0,0 +1,114 @@
package filer_etc
import (
"bytes"
"context"
"encoding/json"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
type PoliciesCollection struct {
Policies map[string]credential.PolicyDocument `json:"policies"`
}
// GetPolicies retrieves all IAM policies from the filer
func (store *FilerEtcStore) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) {
policiesCollection := &PoliciesCollection{
Policies: make(map[string]credential.PolicyDocument),
}
// Check if filer client is configured
if store.filerGrpcAddress == "" {
glog.V(1).Infof("Filer client not configured for policy retrieval, returning empty policies")
// Return empty policies if filer client is not configured
return policiesCollection.Policies, nil
}
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
var buf bytes.Buffer
if err := filer.ReadEntry(nil, client, filer.IamConfigDirectory, filer.IamPoliciesFile, &buf); err != nil {
if err == filer_pb.ErrNotFound {
glog.V(1).Infof("Policies file not found at %s/%s, returning empty policies", filer.IamConfigDirectory, filer.IamPoliciesFile)
// If file doesn't exist, return empty collection
return nil
}
return err
}
if buf.Len() > 0 {
return json.Unmarshal(buf.Bytes(), policiesCollection)
}
return nil
})
if err != nil {
return nil, err
}
return policiesCollection.Policies, nil
}
// CreatePolicy creates a new IAM policy in the filer
func (store *FilerEtcStore) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
return store.updatePolicies(ctx, func(policies map[string]credential.PolicyDocument) {
policies[name] = document
})
}
// UpdatePolicy updates an existing IAM policy in the filer
func (store *FilerEtcStore) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
return store.updatePolicies(ctx, func(policies map[string]credential.PolicyDocument) {
policies[name] = document
})
}
// DeletePolicy deletes an IAM policy from the filer
func (store *FilerEtcStore) DeletePolicy(ctx context.Context, name string) error {
return store.updatePolicies(ctx, func(policies map[string]credential.PolicyDocument) {
delete(policies, name)
})
}
// updatePolicies is a helper method to update policies atomically
func (store *FilerEtcStore) updatePolicies(ctx context.Context, updateFunc func(map[string]credential.PolicyDocument)) error {
// Load existing policies
policies, err := store.GetPolicies(ctx)
if err != nil {
return err
}
// Apply update
updateFunc(policies)
// Save back to filer
policiesCollection := &PoliciesCollection{
Policies: policies,
}
data, err := json.Marshal(policiesCollection)
if err != nil {
return err
}
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamPoliciesFile, data)
})
}
// GetPolicy retrieves a specific IAM policy by name from the filer
func (store *FilerEtcStore) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) {
policies, err := store.GetPolicies(ctx)
if err != nil {
return nil, err
}
if policy, exists := policies[name]; exists {
return &policy, nil
}
return nil, nil // Policy not found
}

180
weed/credential/filer_etc/filer_etc_store.go

@ -1,15 +1,11 @@
package filer_etc package filer_etc
import ( import (
"bytes"
"context"
"fmt" "fmt"
"github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb" "github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/util" "github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc" "google.golang.org/grpc"
) )
@ -54,182 +50,6 @@ func (store *FilerEtcStore) withFilerClient(fn func(client filer_pb.SeaweedFiler
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(store.filerGrpcAddress), store.grpcDialOption, fn) return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(store.filerGrpcAddress), store.grpcDialOption, fn)
} }
func (store *FilerEtcStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
s3cfg := &iam_pb.S3ApiConfiguration{}
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
var buf bytes.Buffer
if err := filer.ReadEntry(nil, client, filer.IamConfigDirectory, filer.IamIdentityFile, &buf); err != nil {
if err != filer_pb.ErrNotFound {
return err
}
}
if buf.Len() > 0 {
return filer.ParseS3ConfigurationFromBytes(buf.Bytes(), s3cfg)
}
return nil
})
return s3cfg, err
}
func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
var buf bytes.Buffer
if err := filer.ProtoToText(&buf, config); err != nil {
return fmt.Errorf("failed to marshal configuration: %v", err)
}
return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamIdentityFile, buf.Bytes())
})
}
func (store *FilerEtcStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
// Load existing configuration
config, err := store.LoadConfiguration(ctx)
if err != nil {
return fmt.Errorf("failed to load configuration: %v", err)
}
// Check if user already exists
for _, existingIdentity := range config.Identities {
if existingIdentity.Name == identity.Name {
return credential.ErrUserAlreadyExists
}
}
// Add new identity
config.Identities = append(config.Identities, identity)
// Save configuration
return store.SaveConfiguration(ctx, config)
}
func (store *FilerEtcStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load configuration: %v", err)
}
for _, identity := range config.Identities {
if identity.Name == username {
return identity, nil
}
}
return nil, credential.ErrUserNotFound
}
func (store *FilerEtcStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return fmt.Errorf("failed to load configuration: %v", err)
}
// Find and update the user
for i, existingIdentity := range config.Identities {
if existingIdentity.Name == username {
config.Identities[i] = identity
return store.SaveConfiguration(ctx, config)
}
}
return credential.ErrUserNotFound
}
func (store *FilerEtcStore) DeleteUser(ctx context.Context, username string) error {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return fmt.Errorf("failed to load configuration: %v", err)
}
// Find and remove the user
for i, identity := range config.Identities {
if identity.Name == username {
config.Identities = append(config.Identities[:i], config.Identities[i+1:]...)
return store.SaveConfiguration(ctx, config)
}
}
return credential.ErrUserNotFound
}
func (store *FilerEtcStore) ListUsers(ctx context.Context) ([]string, error) {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load configuration: %v", err)
}
var usernames []string
for _, identity := range config.Identities {
usernames = append(usernames, identity.Name)
}
return usernames, nil
}
func (store *FilerEtcStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load configuration: %v", err)
}
for _, identity := range config.Identities {
for _, credential := range identity.Credentials {
if credential.AccessKey == accessKey {
return identity, nil
}
}
}
return nil, credential.ErrAccessKeyNotFound
}
func (store *FilerEtcStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return fmt.Errorf("failed to load configuration: %v", err)
}
// Find the user and add the credential
for _, identity := range config.Identities {
if identity.Name == username {
// Check if access key already exists
for _, existingCred := range identity.Credentials {
if existingCred.AccessKey == cred.AccessKey {
return fmt.Errorf("access key %s already exists", cred.AccessKey)
}
}
identity.Credentials = append(identity.Credentials, cred)
return store.SaveConfiguration(ctx, config)
}
}
return credential.ErrUserNotFound
}
func (store *FilerEtcStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return fmt.Errorf("failed to load configuration: %v", err)
}
// Find the user and remove the credential
for _, identity := range config.Identities {
if identity.Name == username {
for i, cred := range identity.Credentials {
if cred.AccessKey == accessKey {
identity.Credentials = append(identity.Credentials[:i], identity.Credentials[i+1:]...)
return store.SaveConfiguration(ctx, config)
}
}
return credential.ErrAccessKeyNotFound
}
}
return credential.ErrUserNotFound
}
func (store *FilerEtcStore) Shutdown() { func (store *FilerEtcStore) Shutdown() {
// No cleanup needed for file store // No cleanup needed for file store
} }

302
weed/credential/memory/memory_identity.go

@ -0,0 +1,302 @@
package memory
import (
"context"
"encoding/json"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
func (store *MemoryStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
config := &iam_pb.S3ApiConfiguration{}
// Convert all users to identities
for _, user := range store.users {
// Deep copy the identity to avoid mutation issues
identityCopy := store.deepCopyIdentity(user)
config.Identities = append(config.Identities, identityCopy)
}
return config, nil
}
func (store *MemoryStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
// Clear existing data
store.users = make(map[string]*iam_pb.Identity)
store.accessKeys = make(map[string]string)
// Add all identities
for _, identity := range config.Identities {
// Deep copy to avoid mutation issues
identityCopy := store.deepCopyIdentity(identity)
store.users[identity.Name] = identityCopy
// Index access keys
for _, credential := range identity.Credentials {
store.accessKeys[credential.AccessKey] = identity.Name
}
}
return nil
}
func (store *MemoryStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
if _, exists := store.users[identity.Name]; exists {
return credential.ErrUserAlreadyExists
}
// Check for duplicate access keys
for _, cred := range identity.Credentials {
if _, exists := store.accessKeys[cred.AccessKey]; exists {
return fmt.Errorf("access key %s already exists", cred.AccessKey)
}
}
// Deep copy to avoid mutation issues
identityCopy := store.deepCopyIdentity(identity)
store.users[identity.Name] = identityCopy
// Index access keys
for _, cred := range identity.Credentials {
store.accessKeys[cred.AccessKey] = identity.Name
}
return nil
}
func (store *MemoryStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return nil, credential.ErrUserNotFound
}
// Return a deep copy to avoid mutation issues
return store.deepCopyIdentity(user), nil
}
func (store *MemoryStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
existingUser, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Remove old access keys from index
for _, cred := range existingUser.Credentials {
delete(store.accessKeys, cred.AccessKey)
}
// Check for duplicate access keys (excluding current user)
for _, cred := range identity.Credentials {
if existingUsername, exists := store.accessKeys[cred.AccessKey]; exists && existingUsername != username {
return fmt.Errorf("access key %s already exists", cred.AccessKey)
}
}
// Deep copy to avoid mutation issues
identityCopy := store.deepCopyIdentity(identity)
store.users[username] = identityCopy
// Re-index access keys
for _, cred := range identity.Credentials {
store.accessKeys[cred.AccessKey] = username
}
return nil
}
func (store *MemoryStore) DeleteUser(ctx context.Context, username string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Remove access keys from index
for _, cred := range user.Credentials {
delete(store.accessKeys, cred.AccessKey)
}
// Remove user
delete(store.users, username)
return nil
}
func (store *MemoryStore) ListUsers(ctx context.Context) ([]string, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
var usernames []string
for username := range store.users {
usernames = append(usernames, username)
}
return usernames, nil
}
func (store *MemoryStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
username, exists := store.accessKeys[accessKey]
if !exists {
return nil, credential.ErrAccessKeyNotFound
}
user, exists := store.users[username]
if !exists {
// This should not happen, but handle it gracefully
return nil, credential.ErrUserNotFound
}
// Return a deep copy to avoid mutation issues
return store.deepCopyIdentity(user), nil
}
func (store *MemoryStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Check if access key already exists
if _, exists := store.accessKeys[cred.AccessKey]; exists {
return fmt.Errorf("access key %s already exists", cred.AccessKey)
}
// Add credential to user
user.Credentials = append(user.Credentials, &iam_pb.Credential{
AccessKey: cred.AccessKey,
SecretKey: cred.SecretKey,
})
// Index the access key
store.accessKeys[cred.AccessKey] = username
return nil
}
func (store *MemoryStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Find and remove the credential
var newCredentials []*iam_pb.Credential
found := false
for _, cred := range user.Credentials {
if cred.AccessKey == accessKey {
found = true
// Remove from access key index
delete(store.accessKeys, accessKey)
} else {
newCredentials = append(newCredentials, cred)
}
}
if !found {
return credential.ErrAccessKeyNotFound
}
user.Credentials = newCredentials
return nil
}
// deepCopyIdentity creates a deep copy of an identity to avoid mutation issues
func (store *MemoryStore) deepCopyIdentity(identity *iam_pb.Identity) *iam_pb.Identity {
if identity == nil {
return nil
}
// Use JSON marshaling/unmarshaling for deep copy
// This is simple and safe for protobuf messages
data, err := json.Marshal(identity)
if err != nil {
// Fallback to shallow copy if JSON fails
return &iam_pb.Identity{
Name: identity.Name,
Account: identity.Account,
Credentials: identity.Credentials,
Actions: identity.Actions,
}
}
var copy iam_pb.Identity
if err := json.Unmarshal(data, &copy); err != nil {
// Fallback to shallow copy if JSON fails
return &iam_pb.Identity{
Name: identity.Name,
Account: identity.Account,
Credentials: identity.Credentials,
Actions: identity.Actions,
}
}
return &copy
}

77
weed/credential/memory/memory_policy.go

@ -0,0 +1,77 @@
package memory
import (
"context"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
)
// GetPolicies retrieves all IAM policies from memory
func (store *MemoryStore) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
// Create a copy of the policies map to avoid mutation issues
policies := make(map[string]credential.PolicyDocument)
for name, doc := range store.policies {
policies[name] = doc
}
return policies, nil
}
// GetPolicy retrieves a specific IAM policy by name from memory
func (store *MemoryStore) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if policy, exists := store.policies[name]; exists {
return &policy, nil
}
return nil, nil // Policy not found
}
// CreatePolicy creates a new IAM policy in memory
func (store *MemoryStore) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
store.policies[name] = document
return nil
}
// UpdatePolicy updates an existing IAM policy in memory
func (store *MemoryStore) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
store.policies[name] = document
return nil
}
// DeletePolicy deletes an IAM policy from memory
func (store *MemoryStore) DeletePolicy(ctx context.Context, name string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
delete(store.policies, name)
return nil
}

303
weed/credential/memory/memory_store.go

@ -1,9 +1,6 @@
package memory package memory
import ( import (
"context"
"encoding/json"
"fmt"
"sync" "sync"
"github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/credential"
@ -19,8 +16,9 @@ func init() {
// This is primarily intended for testing purposes // This is primarily intended for testing purposes
type MemoryStore struct { type MemoryStore struct {
mu sync.RWMutex mu sync.RWMutex
users map[string]*iam_pb.Identity // username -> identity
accessKeys map[string]string // access_key -> username
users map[string]*iam_pb.Identity // username -> identity
accessKeys map[string]string // access_key -> username
policies map[string]credential.PolicyDocument // policy_name -> policy_document
initialized bool initialized bool
} }
@ -38,313 +36,22 @@ func (store *MemoryStore) Initialize(configuration util.Configuration, prefix st
store.users = make(map[string]*iam_pb.Identity) store.users = make(map[string]*iam_pb.Identity)
store.accessKeys = make(map[string]string) store.accessKeys = make(map[string]string)
store.policies = make(map[string]credential.PolicyDocument)
store.initialized = true store.initialized = true
return nil return nil
} }
func (store *MemoryStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
config := &iam_pb.S3ApiConfiguration{}
// Convert all users to identities
for _, user := range store.users {
// Deep copy the identity to avoid mutation issues
identityCopy := store.deepCopyIdentity(user)
config.Identities = append(config.Identities, identityCopy)
}
return config, nil
}
func (store *MemoryStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
// Clear existing data
store.users = make(map[string]*iam_pb.Identity)
store.accessKeys = make(map[string]string)
// Add all identities
for _, identity := range config.Identities {
// Deep copy to avoid mutation issues
identityCopy := store.deepCopyIdentity(identity)
store.users[identity.Name] = identityCopy
// Index access keys
for _, credential := range identity.Credentials {
store.accessKeys[credential.AccessKey] = identity.Name
}
}
return nil
}
func (store *MemoryStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
if _, exists := store.users[identity.Name]; exists {
return credential.ErrUserAlreadyExists
}
// Check for duplicate access keys
for _, cred := range identity.Credentials {
if _, exists := store.accessKeys[cred.AccessKey]; exists {
return fmt.Errorf("access key %s already exists", cred.AccessKey)
}
}
// Deep copy to avoid mutation issues
identityCopy := store.deepCopyIdentity(identity)
store.users[identity.Name] = identityCopy
// Index access keys
for _, cred := range identity.Credentials {
store.accessKeys[cred.AccessKey] = identity.Name
}
return nil
}
func (store *MemoryStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return nil, credential.ErrUserNotFound
}
// Return a deep copy to avoid mutation issues
return store.deepCopyIdentity(user), nil
}
func (store *MemoryStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
existingUser, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Remove old access keys from index
for _, cred := range existingUser.Credentials {
delete(store.accessKeys, cred.AccessKey)
}
// Check for duplicate access keys (excluding current user)
for _, cred := range identity.Credentials {
if existingUsername, exists := store.accessKeys[cred.AccessKey]; exists && existingUsername != username {
return fmt.Errorf("access key %s already exists", cred.AccessKey)
}
}
// Deep copy to avoid mutation issues
identityCopy := store.deepCopyIdentity(identity)
store.users[username] = identityCopy
// Re-index access keys
for _, cred := range identity.Credentials {
store.accessKeys[cred.AccessKey] = username
}
return nil
}
func (store *MemoryStore) DeleteUser(ctx context.Context, username string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Remove access keys from index
for _, cred := range user.Credentials {
delete(store.accessKeys, cred.AccessKey)
}
// Remove user
delete(store.users, username)
return nil
}
func (store *MemoryStore) ListUsers(ctx context.Context) ([]string, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
var usernames []string
for username := range store.users {
usernames = append(usernames, username)
}
return usernames, nil
}
func (store *MemoryStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
username, exists := store.accessKeys[accessKey]
if !exists {
return nil, credential.ErrAccessKeyNotFound
}
user, exists := store.users[username]
if !exists {
// This should not happen, but handle it gracefully
return nil, credential.ErrUserNotFound
}
// Return a deep copy to avoid mutation issues
return store.deepCopyIdentity(user), nil
}
func (store *MemoryStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Check if access key already exists
if _, exists := store.accessKeys[cred.AccessKey]; exists {
return fmt.Errorf("access key %s already exists", cred.AccessKey)
}
// Add credential to user
user.Credentials = append(user.Credentials, &iam_pb.Credential{
AccessKey: cred.AccessKey,
SecretKey: cred.SecretKey,
})
// Index the access key
store.accessKeys[cred.AccessKey] = username
return nil
}
func (store *MemoryStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Find and remove the credential
var newCredentials []*iam_pb.Credential
found := false
for _, cred := range user.Credentials {
if cred.AccessKey == accessKey {
found = true
// Remove from access key index
delete(store.accessKeys, accessKey)
} else {
newCredentials = append(newCredentials, cred)
}
}
if !found {
return credential.ErrAccessKeyNotFound
}
user.Credentials = newCredentials
return nil
}
func (store *MemoryStore) Shutdown() { func (store *MemoryStore) Shutdown() {
store.mu.Lock() store.mu.Lock()
defer store.mu.Unlock() defer store.mu.Unlock()
// Clear all data
store.users = nil store.users = nil
store.accessKeys = nil store.accessKeys = nil
store.policies = nil
store.initialized = false store.initialized = false
} }
// deepCopyIdentity creates a deep copy of an identity to avoid mutation issues
func (store *MemoryStore) deepCopyIdentity(identity *iam_pb.Identity) *iam_pb.Identity {
if identity == nil {
return nil
}
// Use JSON marshaling/unmarshaling for deep copy
// This is simple and safe for protobuf messages
data, err := json.Marshal(identity)
if err != nil {
// Fallback to shallow copy if JSON fails
return &iam_pb.Identity{
Name: identity.Name,
Account: identity.Account,
Credentials: identity.Credentials,
Actions: identity.Actions,
}
}
var copy iam_pb.Identity
if err := json.Unmarshal(data, &copy); err != nil {
// Fallback to shallow copy if JSON fails
return &iam_pb.Identity{
Name: identity.Name,
Account: identity.Account,
Credentials: identity.Credentials,
Actions: identity.Actions,
}
}
return &copy
}
// Reset clears all data in the store (useful for testing) // Reset clears all data in the store (useful for testing)
func (store *MemoryStore) Reset() { func (store *MemoryStore) Reset() {
store.mu.Lock() store.mu.Lock()

446
weed/credential/postgres/postgres_identity.go

@ -0,0 +1,446 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
func (store *PostgresStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
config := &iam_pb.S3ApiConfiguration{}
// Query all users
rows, err := store.db.QueryContext(ctx, "SELECT username, email, account_data, actions FROM users")
if err != nil {
return nil, fmt.Errorf("failed to query users: %v", err)
}
defer rows.Close()
for rows.Next() {
var username, email string
var accountDataJSON, actionsJSON []byte
if err := rows.Scan(&username, &email, &accountDataJSON, &actionsJSON); err != nil {
return nil, fmt.Errorf("failed to scan user row: %v", err)
}
identity := &iam_pb.Identity{
Name: username,
}
// Parse account data
if len(accountDataJSON) > 0 {
if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil {
return nil, fmt.Errorf("failed to unmarshal account data for user %s: %v", username, err)
}
}
// Parse actions
if len(actionsJSON) > 0 {
if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil {
return nil, fmt.Errorf("failed to unmarshal actions for user %s: %v", username, err)
}
}
// Query credentials for this user
credRows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username)
if err != nil {
return nil, fmt.Errorf("failed to query credentials for user %s: %v", username, err)
}
for credRows.Next() {
var accessKey, secretKey string
if err := credRows.Scan(&accessKey, &secretKey); err != nil {
credRows.Close()
return nil, fmt.Errorf("failed to scan credential row for user %s: %v", username, err)
}
identity.Credentials = append(identity.Credentials, &iam_pb.Credential{
AccessKey: accessKey,
SecretKey: secretKey,
})
}
credRows.Close()
config.Identities = append(config.Identities, identity)
}
return config, nil
}
func (store *PostgresStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
// Start transaction
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Clear existing data
if _, err := tx.ExecContext(ctx, "DELETE FROM credentials"); err != nil {
return fmt.Errorf("failed to clear credentials: %v", err)
}
if _, err := tx.ExecContext(ctx, "DELETE FROM users"); err != nil {
return fmt.Errorf("failed to clear users: %v", err)
}
// Insert all identities
for _, identity := range config.Identities {
// Marshal account data
var accountDataJSON []byte
if identity.Account != nil {
accountDataJSON, err = json.Marshal(identity.Account)
if err != nil {
return fmt.Errorf("failed to marshal account data for user %s: %v", identity.Name, err)
}
}
// Marshal actions
var actionsJSON []byte
if identity.Actions != nil {
actionsJSON, err = json.Marshal(identity.Actions)
if err != nil {
return fmt.Errorf("failed to marshal actions for user %s: %v", identity.Name, err)
}
}
// Insert user
_, err := tx.ExecContext(ctx,
"INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)",
identity.Name, "", accountDataJSON, actionsJSON)
if err != nil {
return fmt.Errorf("failed to insert user %s: %v", identity.Name, err)
}
// Insert credentials
for _, cred := range identity.Credentials {
_, err := tx.ExecContext(ctx,
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
identity.Name, cred.AccessKey, cred.SecretKey)
if err != nil {
return fmt.Errorf("failed to insert credential for user %s: %v", identity.Name, err)
}
}
}
return tx.Commit()
}
func (store *PostgresStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
// Check if user already exists
var count int
err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", identity.Name).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check user existence: %v", err)
}
if count > 0 {
return credential.ErrUserAlreadyExists
}
// Start transaction
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Marshal account data
var accountDataJSON []byte
if identity.Account != nil {
accountDataJSON, err = json.Marshal(identity.Account)
if err != nil {
return fmt.Errorf("failed to marshal account data: %v", err)
}
}
// Marshal actions
var actionsJSON []byte
if identity.Actions != nil {
actionsJSON, err = json.Marshal(identity.Actions)
if err != nil {
return fmt.Errorf("failed to marshal actions: %v", err)
}
}
// Insert user
_, err = tx.ExecContext(ctx,
"INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)",
identity.Name, "", accountDataJSON, actionsJSON)
if err != nil {
return fmt.Errorf("failed to insert user: %v", err)
}
// Insert credentials
for _, cred := range identity.Credentials {
_, err = tx.ExecContext(ctx,
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
identity.Name, cred.AccessKey, cred.SecretKey)
if err != nil {
return fmt.Errorf("failed to insert credential: %v", err)
}
}
return tx.Commit()
}
func (store *PostgresStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
var email string
var accountDataJSON, actionsJSON []byte
err := store.db.QueryRowContext(ctx,
"SELECT email, account_data, actions FROM users WHERE username = $1",
username).Scan(&email, &accountDataJSON, &actionsJSON)
if err != nil {
if err == sql.ErrNoRows {
return nil, credential.ErrUserNotFound
}
return nil, fmt.Errorf("failed to query user: %v", err)
}
identity := &iam_pb.Identity{
Name: username,
}
// Parse account data
if len(accountDataJSON) > 0 {
if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil {
return nil, fmt.Errorf("failed to unmarshal account data: %v", err)
}
}
// Parse actions
if len(actionsJSON) > 0 {
if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil {
return nil, fmt.Errorf("failed to unmarshal actions: %v", err)
}
}
// Query credentials
rows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username)
if err != nil {
return nil, fmt.Errorf("failed to query credentials: %v", err)
}
defer rows.Close()
for rows.Next() {
var accessKey, secretKey string
if err := rows.Scan(&accessKey, &secretKey); err != nil {
return nil, fmt.Errorf("failed to scan credential: %v", err)
}
identity.Credentials = append(identity.Credentials, &iam_pb.Credential{
AccessKey: accessKey,
SecretKey: secretKey,
})
}
return identity, nil
}
func (store *PostgresStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
// Start transaction
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Check if user exists
var count int
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check user existence: %v", err)
}
if count == 0 {
return credential.ErrUserNotFound
}
// Marshal account data
var accountDataJSON []byte
if identity.Account != nil {
accountDataJSON, err = json.Marshal(identity.Account)
if err != nil {
return fmt.Errorf("failed to marshal account data: %v", err)
}
}
// Marshal actions
var actionsJSON []byte
if identity.Actions != nil {
actionsJSON, err = json.Marshal(identity.Actions)
if err != nil {
return fmt.Errorf("failed to marshal actions: %v", err)
}
}
// Update user
_, err = tx.ExecContext(ctx,
"UPDATE users SET email = $2, account_data = $3, actions = $4, updated_at = CURRENT_TIMESTAMP WHERE username = $1",
username, "", accountDataJSON, actionsJSON)
if err != nil {
return fmt.Errorf("failed to update user: %v", err)
}
// Delete existing credentials
_, err = tx.ExecContext(ctx, "DELETE FROM credentials WHERE username = $1", username)
if err != nil {
return fmt.Errorf("failed to delete existing credentials: %v", err)
}
// Insert new credentials
for _, cred := range identity.Credentials {
_, err = tx.ExecContext(ctx,
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
username, cred.AccessKey, cred.SecretKey)
if err != nil {
return fmt.Errorf("failed to insert credential: %v", err)
}
}
return tx.Commit()
}
func (store *PostgresStore) DeleteUser(ctx context.Context, username string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
result, err := store.db.ExecContext(ctx, "DELETE FROM users WHERE username = $1", username)
if err != nil {
return fmt.Errorf("failed to delete user: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %v", err)
}
if rowsAffected == 0 {
return credential.ErrUserNotFound
}
return nil
}
func (store *PostgresStore) ListUsers(ctx context.Context) ([]string, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
rows, err := store.db.QueryContext(ctx, "SELECT username FROM users ORDER BY username")
if err != nil {
return nil, fmt.Errorf("failed to query users: %v", err)
}
defer rows.Close()
var usernames []string
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return nil, fmt.Errorf("failed to scan username: %v", err)
}
usernames = append(usernames, username)
}
return usernames, nil
}
func (store *PostgresStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
var username string
err := store.db.QueryRowContext(ctx, "SELECT username FROM credentials WHERE access_key = $1", accessKey).Scan(&username)
if err != nil {
if err == sql.ErrNoRows {
return nil, credential.ErrAccessKeyNotFound
}
return nil, fmt.Errorf("failed to query access key: %v", err)
}
return store.GetUser(ctx, username)
}
func (store *PostgresStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
// Check if user exists
var count int
err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check user existence: %v", err)
}
if count == 0 {
return credential.ErrUserNotFound
}
// Insert credential
_, err = store.db.ExecContext(ctx,
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
username, cred.AccessKey, cred.SecretKey)
if err != nil {
return fmt.Errorf("failed to insert credential: %v", err)
}
return nil
}
func (store *PostgresStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
result, err := store.db.ExecContext(ctx,
"DELETE FROM credentials WHERE username = $1 AND access_key = $2",
username, accessKey)
if err != nil {
return fmt.Errorf("failed to delete access key: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %v", err)
}
if rowsAffected == 0 {
// Check if user exists
var count int
err = store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check user existence: %v", err)
}
if count == 0 {
return credential.ErrUserNotFound
}
return credential.ErrAccessKeyNotFound
}
return nil
}

130
weed/credential/postgres/postgres_policy.go

@ -0,0 +1,130 @@
package postgres
import (
"context"
"encoding/json"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
)
// GetPolicies retrieves all IAM policies from PostgreSQL
func (store *PostgresStore) GetPolicies(ctx context.Context) (map[string]credential.PolicyDocument, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
policies := make(map[string]credential.PolicyDocument)
rows, err := store.db.QueryContext(ctx, "SELECT name, document FROM policies")
if err != nil {
return nil, fmt.Errorf("failed to query policies: %v", err)
}
defer rows.Close()
for rows.Next() {
var name string
var documentJSON []byte
if err := rows.Scan(&name, &documentJSON); err != nil {
return nil, fmt.Errorf("failed to scan policy row: %v", err)
}
var document credential.PolicyDocument
if err := json.Unmarshal(documentJSON, &document); err != nil {
return nil, fmt.Errorf("failed to unmarshal policy document for %s: %v", name, err)
}
policies[name] = document
}
return policies, nil
}
// CreatePolicy creates a new IAM policy in PostgreSQL
func (store *PostgresStore) CreatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
documentJSON, err := json.Marshal(document)
if err != nil {
return fmt.Errorf("failed to marshal policy document: %v", err)
}
_, err = store.db.ExecContext(ctx,
"INSERT INTO policies (name, document) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET document = $2, updated_at = CURRENT_TIMESTAMP",
name, documentJSON)
if err != nil {
return fmt.Errorf("failed to insert policy: %v", err)
}
return nil
}
// UpdatePolicy updates an existing IAM policy in PostgreSQL
func (store *PostgresStore) UpdatePolicy(ctx context.Context, name string, document credential.PolicyDocument) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
documentJSON, err := json.Marshal(document)
if err != nil {
return fmt.Errorf("failed to marshal policy document: %v", err)
}
result, err := store.db.ExecContext(ctx,
"UPDATE policies SET document = $2, updated_at = CURRENT_TIMESTAMP WHERE name = $1",
name, documentJSON)
if err != nil {
return fmt.Errorf("failed to update policy: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %v", err)
}
if rowsAffected == 0 {
return fmt.Errorf("policy %s not found", name)
}
return nil
}
// DeletePolicy deletes an IAM policy from PostgreSQL
func (store *PostgresStore) DeletePolicy(ctx context.Context, name string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
result, err := store.db.ExecContext(ctx, "DELETE FROM policies WHERE name = $1", name)
if err != nil {
return fmt.Errorf("failed to delete policy: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %v", err)
}
if rowsAffected == 0 {
return fmt.Errorf("policy %s not found", name)
}
return nil
}
// GetPolicy retrieves a specific IAM policy by name from PostgreSQL
func (store *PostgresStore) GetPolicy(ctx context.Context, name string) (*credential.PolicyDocument, error) {
policies, err := store.GetPolicies(ctx)
if err != nil {
return nil, err
}
if policy, exists := policies[name]; exists {
return &policy, nil
}
return nil, nil // Policy not found
}

449
weed/credential/postgres/postgres_store.go

@ -1,14 +1,11 @@
package postgres package postgres
import ( import (
"context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"time" "time"
"github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/util" "github.com/seaweedfs/seaweedfs/weed/util"
_ "github.com/lib/pq" _ "github.com/lib/pq"
@ -114,6 +111,17 @@ func (store *PostgresStore) createTables() error {
CREATE INDEX IF NOT EXISTS idx_credentials_access_key ON credentials(access_key); CREATE INDEX IF NOT EXISTS idx_credentials_access_key ON credentials(access_key);
` `
// Create policies table
policiesTable := `
CREATE TABLE IF NOT EXISTS policies (
name VARCHAR(255) PRIMARY KEY,
document JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_policies_name ON policies(name);
`
// Execute table creation // Execute table creation
if _, err := store.db.Exec(usersTable); err != nil { if _, err := store.db.Exec(usersTable); err != nil {
return fmt.Errorf("failed to create users table: %v", err) return fmt.Errorf("failed to create users table: %v", err)
@ -123,439 +131,8 @@ func (store *PostgresStore) createTables() error {
return fmt.Errorf("failed to create credentials table: %v", err) return fmt.Errorf("failed to create credentials table: %v", err)
} }
return nil
}
func (store *PostgresStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
config := &iam_pb.S3ApiConfiguration{}
// Query all users
rows, err := store.db.QueryContext(ctx, "SELECT username, email, account_data, actions FROM users")
if err != nil {
return nil, fmt.Errorf("failed to query users: %v", err)
}
defer rows.Close()
for rows.Next() {
var username, email string
var accountDataJSON, actionsJSON []byte
if err := rows.Scan(&username, &email, &accountDataJSON, &actionsJSON); err != nil {
return nil, fmt.Errorf("failed to scan user row: %v", err)
}
identity := &iam_pb.Identity{
Name: username,
}
// Parse account data
if len(accountDataJSON) > 0 {
if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil {
return nil, fmt.Errorf("failed to unmarshal account data for user %s: %v", username, err)
}
}
// Parse actions
if len(actionsJSON) > 0 {
if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil {
return nil, fmt.Errorf("failed to unmarshal actions for user %s: %v", username, err)
}
}
// Query credentials for this user
credRows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username)
if err != nil {
return nil, fmt.Errorf("failed to query credentials for user %s: %v", username, err)
}
for credRows.Next() {
var accessKey, secretKey string
if err := credRows.Scan(&accessKey, &secretKey); err != nil {
credRows.Close()
return nil, fmt.Errorf("failed to scan credential row for user %s: %v", username, err)
}
identity.Credentials = append(identity.Credentials, &iam_pb.Credential{
AccessKey: accessKey,
SecretKey: secretKey,
})
}
credRows.Close()
config.Identities = append(config.Identities, identity)
}
return config, nil
}
func (store *PostgresStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
// Start transaction
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Clear existing data
if _, err := tx.ExecContext(ctx, "DELETE FROM credentials"); err != nil {
return fmt.Errorf("failed to clear credentials: %v", err)
}
if _, err := tx.ExecContext(ctx, "DELETE FROM users"); err != nil {
return fmt.Errorf("failed to clear users: %v", err)
}
// Insert all identities
for _, identity := range config.Identities {
// Marshal account data
var accountDataJSON []byte
if identity.Account != nil {
accountDataJSON, err = json.Marshal(identity.Account)
if err != nil {
return fmt.Errorf("failed to marshal account data for user %s: %v", identity.Name, err)
}
}
// Marshal actions
var actionsJSON []byte
if identity.Actions != nil {
actionsJSON, err = json.Marshal(identity.Actions)
if err != nil {
return fmt.Errorf("failed to marshal actions for user %s: %v", identity.Name, err)
}
}
// Insert user
_, err := tx.ExecContext(ctx,
"INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)",
identity.Name, "", accountDataJSON, actionsJSON)
if err != nil {
return fmt.Errorf("failed to insert user %s: %v", identity.Name, err)
}
// Insert credentials
for _, cred := range identity.Credentials {
_, err := tx.ExecContext(ctx,
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
identity.Name, cred.AccessKey, cred.SecretKey)
if err != nil {
return fmt.Errorf("failed to insert credential for user %s: %v", identity.Name, err)
}
}
}
return tx.Commit()
}
func (store *PostgresStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
// Check if user already exists
var count int
err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", identity.Name).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check user existence: %v", err)
}
if count > 0 {
return credential.ErrUserAlreadyExists
}
// Start transaction
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Marshal account data
var accountDataJSON []byte
if identity.Account != nil {
accountDataJSON, err = json.Marshal(identity.Account)
if err != nil {
return fmt.Errorf("failed to marshal account data: %v", err)
}
}
// Marshal actions
var actionsJSON []byte
if identity.Actions != nil {
actionsJSON, err = json.Marshal(identity.Actions)
if err != nil {
return fmt.Errorf("failed to marshal actions: %v", err)
}
}
// Insert user
_, err = tx.ExecContext(ctx,
"INSERT INTO users (username, email, account_data, actions) VALUES ($1, $2, $3, $4)",
identity.Name, "", accountDataJSON, actionsJSON)
if err != nil {
return fmt.Errorf("failed to insert user: %v", err)
}
// Insert credentials
for _, cred := range identity.Credentials {
_, err = tx.ExecContext(ctx,
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
identity.Name, cred.AccessKey, cred.SecretKey)
if err != nil {
return fmt.Errorf("failed to insert credential: %v", err)
}
}
return tx.Commit()
}
func (store *PostgresStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
var email string
var accountDataJSON, actionsJSON []byte
err := store.db.QueryRowContext(ctx,
"SELECT email, account_data, actions FROM users WHERE username = $1",
username).Scan(&email, &accountDataJSON, &actionsJSON)
if err != nil {
if err == sql.ErrNoRows {
return nil, credential.ErrUserNotFound
}
return nil, fmt.Errorf("failed to query user: %v", err)
}
identity := &iam_pb.Identity{
Name: username,
}
// Parse account data
if len(accountDataJSON) > 0 {
if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil {
return nil, fmt.Errorf("failed to unmarshal account data: %v", err)
}
}
// Parse actions
if len(actionsJSON) > 0 {
if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil {
return nil, fmt.Errorf("failed to unmarshal actions: %v", err)
}
}
// Query credentials
rows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username)
if err != nil {
return nil, fmt.Errorf("failed to query credentials: %v", err)
}
defer rows.Close()
for rows.Next() {
var accessKey, secretKey string
if err := rows.Scan(&accessKey, &secretKey); err != nil {
return nil, fmt.Errorf("failed to scan credential: %v", err)
}
identity.Credentials = append(identity.Credentials, &iam_pb.Credential{
AccessKey: accessKey,
SecretKey: secretKey,
})
}
return identity, nil
}
func (store *PostgresStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
// Start transaction
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Check if user exists
var count int
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check user existence: %v", err)
}
if count == 0 {
return credential.ErrUserNotFound
}
// Marshal account data
var accountDataJSON []byte
if identity.Account != nil {
accountDataJSON, err = json.Marshal(identity.Account)
if err != nil {
return fmt.Errorf("failed to marshal account data: %v", err)
}
}
// Marshal actions
var actionsJSON []byte
if identity.Actions != nil {
actionsJSON, err = json.Marshal(identity.Actions)
if err != nil {
return fmt.Errorf("failed to marshal actions: %v", err)
}
}
// Update user
_, err = tx.ExecContext(ctx,
"UPDATE users SET email = $2, account_data = $3, actions = $4, updated_at = CURRENT_TIMESTAMP WHERE username = $1",
username, "", accountDataJSON, actionsJSON)
if err != nil {
return fmt.Errorf("failed to update user: %v", err)
}
// Delete existing credentials
_, err = tx.ExecContext(ctx, "DELETE FROM credentials WHERE username = $1", username)
if err != nil {
return fmt.Errorf("failed to delete existing credentials: %v", err)
}
// Insert new credentials
for _, cred := range identity.Credentials {
_, err = tx.ExecContext(ctx,
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
username, cred.AccessKey, cred.SecretKey)
if err != nil {
return fmt.Errorf("failed to insert credential: %v", err)
}
}
return tx.Commit()
}
func (store *PostgresStore) DeleteUser(ctx context.Context, username string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
result, err := store.db.ExecContext(ctx, "DELETE FROM users WHERE username = $1", username)
if err != nil {
return fmt.Errorf("failed to delete user: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %v", err)
}
if rowsAffected == 0 {
return credential.ErrUserNotFound
}
return nil
}
func (store *PostgresStore) ListUsers(ctx context.Context) ([]string, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
rows, err := store.db.QueryContext(ctx, "SELECT username FROM users ORDER BY username")
if err != nil {
return nil, fmt.Errorf("failed to query users: %v", err)
}
defer rows.Close()
var usernames []string
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return nil, fmt.Errorf("failed to scan username: %v", err)
}
usernames = append(usernames, username)
}
return usernames, nil
}
func (store *PostgresStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
var username string
err := store.db.QueryRowContext(ctx, "SELECT username FROM credentials WHERE access_key = $1", accessKey).Scan(&username)
if err != nil {
if err == sql.ErrNoRows {
return nil, credential.ErrAccessKeyNotFound
}
return nil, fmt.Errorf("failed to query access key: %v", err)
}
return store.GetUser(ctx, username)
}
func (store *PostgresStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
// Check if user exists
var count int
err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check user existence: %v", err)
}
if count == 0 {
return credential.ErrUserNotFound
}
// Insert credential
_, err = store.db.ExecContext(ctx,
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
username, cred.AccessKey, cred.SecretKey)
if err != nil {
return fmt.Errorf("failed to insert credential: %v", err)
}
return nil
}
func (store *PostgresStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
result, err := store.db.ExecContext(ctx,
"DELETE FROM credentials WHERE username = $1 AND access_key = $2",
username, accessKey)
if err != nil {
return fmt.Errorf("failed to delete access key: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %v", err)
}
if rowsAffected == 0 {
// Check if user exists
var count int
err = store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check user existence: %v", err)
}
if count == 0 {
return credential.ErrUserNotFound
}
return credential.ErrAccessKeyNotFound
if _, err := store.db.Exec(policiesTable); err != nil {
return fmt.Errorf("failed to create policies table: %v", err)
} }
return nil return nil

146
weed/credential/test/policy_test.go

@ -0,0 +1,146 @@
package test
import (
"context"
"testing"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/credential/memory"
// Import all store implementations to register them
_ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc"
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
_ "github.com/seaweedfs/seaweedfs/weed/credential/postgres"
)
// TestPolicyManagement tests policy management across all credential stores
func TestPolicyManagement(t *testing.T) {
ctx := context.Background()
// Test with memory store (easiest to test)
credentialManager, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "")
if err != nil {
t.Fatalf("Failed to create credential manager: %v", err)
}
// Test policy operations
testPolicyOperations(t, ctx, credentialManager)
}
func testPolicyOperations(t *testing.T, ctx context.Context, credentialManager *credential.CredentialManager) {
store := credentialManager.GetStore()
// Cast to memory store to access policy methods
memoryStore, ok := store.(*memory.MemoryStore)
if !ok {
t.Skip("Store is not a memory store")
}
// Test GetPolicies (should be empty initially)
policies, err := memoryStore.GetPolicies(ctx)
if err != nil {
t.Fatalf("Failed to get policies: %v", err)
}
if len(policies) != 0 {
t.Errorf("Expected 0 policies, got %d", len(policies))
}
// Test CreatePolicy
testPolicy := credential.PolicyDocument{
Version: "2012-10-17",
Statement: []*credential.PolicyStatement{
{
Effect: "Allow",
Action: []string{"s3:GetObject"},
Resource: []string{"arn:aws:s3:::test-bucket/*"},
},
},
}
err = memoryStore.CreatePolicy(ctx, "test-policy", testPolicy)
if err != nil {
t.Fatalf("Failed to create policy: %v", err)
}
// Test GetPolicies (should have 1 policy now)
policies, err = memoryStore.GetPolicies(ctx)
if err != nil {
t.Fatalf("Failed to get policies: %v", err)
}
if len(policies) != 1 {
t.Errorf("Expected 1 policy, got %d", len(policies))
}
// Verify policy content
policy, exists := policies["test-policy"]
if !exists {
t.Error("test-policy not found")
}
if policy.Version != "2012-10-17" {
t.Errorf("Expected policy version '2012-10-17', got '%s'", policy.Version)
}
if len(policy.Statement) != 1 {
t.Errorf("Expected 1 statement, got %d", len(policy.Statement))
}
// Test UpdatePolicy
updatedPolicy := credential.PolicyDocument{
Version: "2012-10-17",
Statement: []*credential.PolicyStatement{
{
Effect: "Allow",
Action: []string{"s3:GetObject", "s3:PutObject"},
Resource: []string{"arn:aws:s3:::test-bucket/*"},
},
},
}
err = memoryStore.UpdatePolicy(ctx, "test-policy", updatedPolicy)
if err != nil {
t.Fatalf("Failed to update policy: %v", err)
}
// Verify the update
policies, err = memoryStore.GetPolicies(ctx)
if err != nil {
t.Fatalf("Failed to get policies after update: %v", err)
}
updatedPolicyResult, exists := policies["test-policy"]
if !exists {
t.Error("test-policy not found after update")
}
if len(updatedPolicyResult.Statement) != 1 {
t.Errorf("Expected 1 statement after update, got %d", len(updatedPolicyResult.Statement))
}
if len(updatedPolicyResult.Statement[0].Action) != 2 {
t.Errorf("Expected 2 actions after update, got %d", len(updatedPolicyResult.Statement[0].Action))
}
// Test DeletePolicy
err = memoryStore.DeletePolicy(ctx, "test-policy")
if err != nil {
t.Fatalf("Failed to delete policy: %v", err)
}
// Verify deletion
policies, err = memoryStore.GetPolicies(ctx)
if err != nil {
t.Fatalf("Failed to get policies after deletion: %v", err)
}
if len(policies) != 0 {
t.Errorf("Expected 0 policies after deletion, got %d", len(policies))
}
}
// TestPolicyManagementWithFilerEtc tests policy management with filer_etc store
func TestPolicyManagementWithFilerEtc(t *testing.T) {
// Skip this test if we can't connect to a filer
t.Skip("Filer connection required for filer_etc store testing")
}
// TestPolicyManagementWithPostgres tests policy management with postgres store
func TestPolicyManagementWithPostgres(t *testing.T) {
// Skip this test if we can't connect to PostgreSQL
t.Skip("PostgreSQL connection required for postgres store testing")
}

369
weed/worker/tasks/balance/ui_templ.go

@ -1,369 +0,0 @@
package balance
import (
"fmt"
"strconv"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/view/components"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Helper function to format seconds as duration string
func formatDurationFromSeconds(seconds int) string {
d := time.Duration(seconds) * time.Second
return d.String()
}
// Helper functions to convert between seconds and value+unit format
func secondsToValueAndUnit(seconds int) (float64, string) {
if seconds == 0 {
return 0, "minutes"
}
// Try days first
if seconds%(24*3600) == 0 && seconds >= 24*3600 {
return float64(seconds / (24 * 3600)), "days"
}
// Try hours
if seconds%3600 == 0 && seconds >= 3600 {
return float64(seconds / 3600), "hours"
}
// Default to minutes
return float64(seconds / 60), "minutes"
}
func valueAndUnitToSeconds(value float64, unit string) int {
switch unit {
case "days":
return int(value * 24 * 3600)
case "hours":
return int(value * 3600)
case "minutes":
return int(value * 60)
default:
return int(value * 60) // Default to minutes
}
}
// UITemplProvider provides the templ-based UI for balance task configuration
type UITemplProvider struct {
detector *BalanceDetector
scheduler *BalanceScheduler
}
// NewUITemplProvider creates a new balance templ UI provider
func NewUITemplProvider(detector *BalanceDetector, scheduler *BalanceScheduler) *UITemplProvider {
return &UITemplProvider{
detector: detector,
scheduler: scheduler,
}
}
// GetTaskType returns the task type
func (ui *UITemplProvider) GetTaskType() types.TaskType {
return types.TaskTypeBalance
}
// GetDisplayName returns the human-readable name
func (ui *UITemplProvider) GetDisplayName() string {
return "Volume Balance"
}
// GetDescription returns a description of what this task does
func (ui *UITemplProvider) GetDescription() string {
return "Redistributes volumes across volume servers to optimize storage utilization and performance"
}
// GetIcon returns the icon CSS class for this task type
func (ui *UITemplProvider) GetIcon() string {
return "fas fa-balance-scale text-secondary"
}
// RenderConfigSections renders the configuration as templ section data
func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) {
config := ui.getCurrentBalanceConfig()
// Detection settings section
detectionSection := components.ConfigSectionData{
Title: "Detection Settings",
Icon: "fas fa-search",
Description: "Configure when balance tasks should be triggered",
Fields: []interface{}{
components.CheckboxFieldData{
FormFieldData: components.FormFieldData{
Name: "enabled",
Label: "Enable Balance Tasks",
Description: "Whether balance tasks should be automatically created",
},
Checked: config.Enabled,
},
components.NumberFieldData{
FormFieldData: components.FormFieldData{
Name: "imbalance_threshold",
Label: "Imbalance Threshold",
Description: "Trigger balance when storage imbalance exceeds this percentage (0.0-1.0)",
Required: true,
},
Value: config.ImbalanceThreshold,
Step: "0.01",
Min: floatPtr(0.0),
Max: floatPtr(1.0),
},
components.DurationInputFieldData{
FormFieldData: components.FormFieldData{
Name: "scan_interval",
Label: "Scan Interval",
Description: "How often to scan for imbalanced volumes",
Required: true,
},
Seconds: config.ScanIntervalSeconds,
},
},
}
// Scheduling settings section
schedulingSection := components.ConfigSectionData{
Title: "Scheduling Settings",
Icon: "fas fa-clock",
Description: "Configure task scheduling and concurrency",
Fields: []interface{}{
components.NumberFieldData{
FormFieldData: components.FormFieldData{
Name: "max_concurrent",
Label: "Max Concurrent Tasks",
Description: "Maximum number of balance tasks that can run simultaneously",
Required: true,
},
Value: float64(config.MaxConcurrent),
Step: "1",
Min: floatPtr(1),
},
components.NumberFieldData{
FormFieldData: components.FormFieldData{
Name: "min_server_count",
Label: "Minimum Server Count",
Description: "Only balance when at least this many servers are available",
Required: true,
},
Value: float64(config.MinServerCount),
Step: "1",
Min: floatPtr(1),
},
},
}
// Timing constraints section
timingSection := components.ConfigSectionData{
Title: "Timing Constraints",
Icon: "fas fa-calendar-clock",
Description: "Configure when balance operations are allowed",
Fields: []interface{}{
components.CheckboxFieldData{
FormFieldData: components.FormFieldData{
Name: "move_during_off_hours",
Label: "Restrict to Off-Hours",
Description: "Only perform balance operations during off-peak hours",
},
Checked: config.MoveDuringOffHours,
},
components.TextFieldData{
FormFieldData: components.FormFieldData{
Name: "off_hours_start",
Label: "Off-Hours Start Time",
Description: "Start time for off-hours window (e.g., 23:00)",
},
Value: config.OffHoursStart,
},
components.TextFieldData{
FormFieldData: components.FormFieldData{
Name: "off_hours_end",
Label: "Off-Hours End Time",
Description: "End time for off-hours window (e.g., 06:00)",
},
Value: config.OffHoursEnd,
},
},
}
// Performance impact info section
performanceSection := components.ConfigSectionData{
Title: "Performance Considerations",
Icon: "fas fa-exclamation-triangle",
Description: "Important information about balance operations",
Fields: []interface{}{
components.TextFieldData{
FormFieldData: components.FormFieldData{
Name: "performance_info",
Label: "Performance Impact",
Description: "Volume balancing involves data movement and can impact cluster performance",
},
Value: "Enable off-hours restriction to minimize impact on production workloads",
},
components.TextFieldData{
FormFieldData: components.FormFieldData{
Name: "safety_info",
Label: "Safety Requirements",
Description: fmt.Sprintf("Requires at least %d servers to ensure data safety during moves", config.MinServerCount),
},
Value: "Maintains data safety during volume moves between servers",
},
},
}
return []components.ConfigSectionData{detectionSection, schedulingSection, timingSection, performanceSection}, nil
}
// ParseConfigForm parses form data into configuration
func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) {
config := &BalanceConfig{}
// Parse enabled checkbox
config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on"
// Parse imbalance threshold
if thresholdStr := formData["imbalance_threshold"]; len(thresholdStr) > 0 {
if threshold, err := strconv.ParseFloat(thresholdStr[0], 64); err != nil {
return nil, fmt.Errorf("invalid imbalance threshold: %v", err)
} else if threshold < 0 || threshold > 1 {
return nil, fmt.Errorf("imbalance threshold must be between 0.0 and 1.0")
} else {
config.ImbalanceThreshold = threshold
}
}
// Parse scan interval
if valueStr := formData["scan_interval"]; len(valueStr) > 0 {
if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
return nil, fmt.Errorf("invalid scan interval value: %v", err)
} else {
unit := "minutes" // default
if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 {
unit = unitStr[0]
}
config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit)
}
}
// Parse max concurrent
if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 {
if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil {
return nil, fmt.Errorf("invalid max concurrent: %v", err)
} else if concurrent < 1 {
return nil, fmt.Errorf("max concurrent must be at least 1")
} else {
config.MaxConcurrent = concurrent
}
}
// Parse min server count
if serverCountStr := formData["min_server_count"]; len(serverCountStr) > 0 {
if serverCount, err := strconv.Atoi(serverCountStr[0]); err != nil {
return nil, fmt.Errorf("invalid min server count: %v", err)
} else if serverCount < 1 {
return nil, fmt.Errorf("min server count must be at least 1")
} else {
config.MinServerCount = serverCount
}
}
// Parse move during off hours
config.MoveDuringOffHours = len(formData["move_during_off_hours"]) > 0 && formData["move_during_off_hours"][0] == "on"
// Parse off hours start time
if startStr := formData["off_hours_start"]; len(startStr) > 0 {
config.OffHoursStart = startStr[0]
}
// Parse off hours end time
if endStr := formData["off_hours_end"]; len(endStr) > 0 {
config.OffHoursEnd = endStr[0]
}
return config, nil
}
// GetCurrentConfig returns the current configuration
func (ui *UITemplProvider) GetCurrentConfig() interface{} {
return ui.getCurrentBalanceConfig()
}
// ApplyConfig applies the new configuration
func (ui *UITemplProvider) ApplyConfig(config interface{}) error {
balanceConfig, ok := config.(*BalanceConfig)
if !ok {
return fmt.Errorf("invalid config type, expected *BalanceConfig")
}
// Apply to detector
if ui.detector != nil {
ui.detector.SetEnabled(balanceConfig.Enabled)
ui.detector.SetThreshold(balanceConfig.ImbalanceThreshold)
ui.detector.SetMinCheckInterval(time.Duration(balanceConfig.ScanIntervalSeconds) * time.Second)
}
// Apply to scheduler
if ui.scheduler != nil {
ui.scheduler.SetEnabled(balanceConfig.Enabled)
ui.scheduler.SetMaxConcurrent(balanceConfig.MaxConcurrent)
ui.scheduler.SetMinServerCount(balanceConfig.MinServerCount)
ui.scheduler.SetMoveDuringOffHours(balanceConfig.MoveDuringOffHours)
ui.scheduler.SetOffHoursStart(balanceConfig.OffHoursStart)
ui.scheduler.SetOffHoursEnd(balanceConfig.OffHoursEnd)
}
glog.V(1).Infof("Applied balance configuration: enabled=%v, threshold=%.1f%%, max_concurrent=%d, min_servers=%d, off_hours=%v",
balanceConfig.Enabled, balanceConfig.ImbalanceThreshold*100, balanceConfig.MaxConcurrent,
balanceConfig.MinServerCount, balanceConfig.MoveDuringOffHours)
return nil
}
// getCurrentBalanceConfig gets the current configuration from detector and scheduler
func (ui *UITemplProvider) getCurrentBalanceConfig() *BalanceConfig {
config := &BalanceConfig{
// Default values (fallback if detectors/schedulers are nil)
Enabled: true,
ImbalanceThreshold: 0.1, // 10% imbalance
ScanIntervalSeconds: int((4 * time.Hour).Seconds()),
MaxConcurrent: 1,
MinServerCount: 3,
MoveDuringOffHours: true,
OffHoursStart: "23:00",
OffHoursEnd: "06:00",
}
// Get current values from detector
if ui.detector != nil {
config.Enabled = ui.detector.IsEnabled()
config.ImbalanceThreshold = ui.detector.GetThreshold()
config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds())
}
// Get current values from scheduler
if ui.scheduler != nil {
config.MaxConcurrent = ui.scheduler.GetMaxConcurrent()
config.MinServerCount = ui.scheduler.GetMinServerCount()
config.MoveDuringOffHours = ui.scheduler.GetMoveDuringOffHours()
config.OffHoursStart = ui.scheduler.GetOffHoursStart()
config.OffHoursEnd = ui.scheduler.GetOffHoursEnd()
}
return config
}
// floatPtr is a helper function to create float64 pointers
func floatPtr(f float64) *float64 {
return &f
}
// RegisterUITempl registers the balance templ UI provider with the UI registry
func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *BalanceDetector, scheduler *BalanceScheduler) {
uiProvider := NewUITemplProvider(detector, scheduler)
uiRegistry.RegisterUI(uiProvider)
glog.V(1).Infof("✅ Registered balance task templ UI provider")
}

319
weed/worker/tasks/erasure_coding/ui_templ.go

@ -1,319 +0,0 @@
package erasure_coding
import (
"fmt"
"strconv"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/view/components"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Helper function to format seconds as duration string
func formatDurationFromSeconds(seconds int) string {
d := time.Duration(seconds) * time.Second
return d.String()
}
// Helper function to convert value and unit to seconds
func valueAndUnitToSeconds(value float64, unit string) int {
switch unit {
case "days":
return int(value * 24 * 60 * 60)
case "hours":
return int(value * 60 * 60)
case "minutes":
return int(value * 60)
default:
return int(value * 60) // Default to minutes
}
}
// UITemplProvider provides the templ-based UI for erasure coding task configuration
type UITemplProvider struct {
detector *EcDetector
scheduler *Scheduler
}
// NewUITemplProvider creates a new erasure coding templ UI provider
func NewUITemplProvider(detector *EcDetector, scheduler *Scheduler) *UITemplProvider {
return &UITemplProvider{
detector: detector,
scheduler: scheduler,
}
}
// ErasureCodingConfig is defined in ui.go - we reuse it
// GetTaskType returns the task type
func (ui *UITemplProvider) GetTaskType() types.TaskType {
return types.TaskTypeErasureCoding
}
// GetDisplayName returns the human-readable name
func (ui *UITemplProvider) GetDisplayName() string {
return "Erasure Coding"
}
// GetDescription returns a description of what this task does
func (ui *UITemplProvider) GetDescription() string {
return "Converts replicated volumes to erasure-coded format for efficient storage"
}
// GetIcon returns the icon CSS class for this task type
func (ui *UITemplProvider) GetIcon() string {
return "fas fa-shield-alt text-info"
}
// RenderConfigSections renders the configuration as templ section data
func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) {
config := ui.getCurrentECConfig()
// Detection settings section
detectionSection := components.ConfigSectionData{
Title: "Detection Settings",
Icon: "fas fa-search",
Description: "Configure when erasure coding tasks should be triggered",
Fields: []interface{}{
components.CheckboxFieldData{
FormFieldData: components.FormFieldData{
Name: "enabled",
Label: "Enable Erasure Coding Tasks",
Description: "Whether erasure coding tasks should be automatically created",
},
Checked: config.Enabled,
},
components.DurationInputFieldData{
FormFieldData: components.FormFieldData{
Name: "scan_interval",
Label: "Scan Interval",
Description: "How often to scan for volumes needing erasure coding",
Required: true,
},
Seconds: config.ScanIntervalSeconds,
},
components.DurationInputFieldData{
FormFieldData: components.FormFieldData{
Name: "volume_age_threshold",
Label: "Volume Age Threshold",
Description: "Only apply erasure coding to volumes older than this age",
Required: true,
},
Seconds: config.VolumeAgeHoursSeconds,
},
},
}
// Erasure coding parameters section
paramsSection := components.ConfigSectionData{
Title: "Erasure Coding Parameters",
Icon: "fas fa-cogs",
Description: "Configure erasure coding scheme and performance",
Fields: []interface{}{
components.NumberFieldData{
FormFieldData: components.FormFieldData{
Name: "data_shards",
Label: "Data Shards",
Description: "Number of data shards in the erasure coding scheme",
Required: true,
},
Value: float64(config.ShardCount),
Step: "1",
Min: floatPtr(1),
Max: floatPtr(16),
},
components.NumberFieldData{
FormFieldData: components.FormFieldData{
Name: "parity_shards",
Label: "Parity Shards",
Description: "Number of parity shards (determines fault tolerance)",
Required: true,
},
Value: float64(config.ParityCount),
Step: "1",
Min: floatPtr(1),
Max: floatPtr(16),
},
components.NumberFieldData{
FormFieldData: components.FormFieldData{
Name: "max_concurrent",
Label: "Max Concurrent Tasks",
Description: "Maximum number of erasure coding tasks that can run simultaneously",
Required: true,
},
Value: float64(config.MaxConcurrent),
Step: "1",
Min: floatPtr(1),
},
},
}
// Performance impact info section
infoSection := components.ConfigSectionData{
Title: "Performance Impact",
Icon: "fas fa-info-circle",
Description: "Important information about erasure coding operations",
Fields: []interface{}{
components.TextFieldData{
FormFieldData: components.FormFieldData{
Name: "durability_info",
Label: "Durability",
Description: fmt.Sprintf("With %d+%d configuration, can tolerate up to %d shard failures",
config.ShardCount, config.ParityCount, config.ParityCount),
},
Value: "High durability with space efficiency",
},
components.TextFieldData{
FormFieldData: components.FormFieldData{
Name: "performance_info",
Label: "Performance Note",
Description: "Erasure coding is CPU and I/O intensive. Consider running during off-peak hours",
},
Value: "Schedule during low-traffic periods",
},
},
}
return []components.ConfigSectionData{detectionSection, paramsSection, infoSection}, nil
}
// ParseConfigForm parses form data into configuration
func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) {
config := &ErasureCodingConfig{}
// Parse enabled checkbox
config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on"
// Parse volume age threshold
if valueStr := formData["volume_age_threshold"]; len(valueStr) > 0 {
if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
return nil, fmt.Errorf("invalid volume age threshold value: %v", err)
} else {
unit := "hours" // default
if unitStr := formData["volume_age_threshold_unit"]; len(unitStr) > 0 {
unit = unitStr[0]
}
config.VolumeAgeHoursSeconds = valueAndUnitToSeconds(value, unit)
}
}
// Parse scan interval
if valueStr := formData["scan_interval"]; len(valueStr) > 0 {
if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
return nil, fmt.Errorf("invalid scan interval value: %v", err)
} else {
unit := "hours" // default
if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 {
unit = unitStr[0]
}
config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit)
}
}
// Parse data shards
if shardsStr := formData["data_shards"]; len(shardsStr) > 0 {
if shards, err := strconv.Atoi(shardsStr[0]); err != nil {
return nil, fmt.Errorf("invalid data shards: %v", err)
} else if shards < 1 || shards > 16 {
return nil, fmt.Errorf("data shards must be between 1 and 16")
} else {
config.ShardCount = shards
}
}
// Parse parity shards
if shardsStr := formData["parity_shards"]; len(shardsStr) > 0 {
if shards, err := strconv.Atoi(shardsStr[0]); err != nil {
return nil, fmt.Errorf("invalid parity shards: %v", err)
} else if shards < 1 || shards > 16 {
return nil, fmt.Errorf("parity shards must be between 1 and 16")
} else {
config.ParityCount = shards
}
}
// Parse max concurrent
if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 {
if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil {
return nil, fmt.Errorf("invalid max concurrent: %v", err)
} else if concurrent < 1 {
return nil, fmt.Errorf("max concurrent must be at least 1")
} else {
config.MaxConcurrent = concurrent
}
}
return config, nil
}
// GetCurrentConfig returns the current configuration
func (ui *UITemplProvider) GetCurrentConfig() interface{} {
return ui.getCurrentECConfig()
}
// ApplyConfig applies the new configuration
func (ui *UITemplProvider) ApplyConfig(config interface{}) error {
ecConfig, ok := config.(*ErasureCodingConfig)
if !ok {
return fmt.Errorf("invalid config type, expected *ErasureCodingConfig")
}
// Apply to detector
if ui.detector != nil {
ui.detector.SetEnabled(ecConfig.Enabled)
ui.detector.SetVolumeAgeHours(ecConfig.VolumeAgeHoursSeconds)
ui.detector.SetScanInterval(time.Duration(ecConfig.ScanIntervalSeconds) * time.Second)
}
// Apply to scheduler
if ui.scheduler != nil {
ui.scheduler.SetMaxConcurrent(ecConfig.MaxConcurrent)
ui.scheduler.SetEnabled(ecConfig.Enabled)
}
glog.V(1).Infof("Applied erasure coding configuration: enabled=%v, age_threshold=%ds, max_concurrent=%d",
ecConfig.Enabled, ecConfig.VolumeAgeHoursSeconds, ecConfig.MaxConcurrent)
return nil
}
// getCurrentECConfig gets the current configuration from detector and scheduler
func (ui *UITemplProvider) getCurrentECConfig() *ErasureCodingConfig {
config := &ErasureCodingConfig{
// Default values (fallback if detectors/schedulers are nil)
Enabled: true,
VolumeAgeHoursSeconds: int((24 * time.Hour).Seconds()),
ScanIntervalSeconds: int((2 * time.Hour).Seconds()),
MaxConcurrent: 1,
ShardCount: 10,
ParityCount: 4,
}
// Get current values from detector
if ui.detector != nil {
config.Enabled = ui.detector.IsEnabled()
config.VolumeAgeHoursSeconds = ui.detector.GetVolumeAgeHours()
config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds())
}
// Get current values from scheduler
if ui.scheduler != nil {
config.MaxConcurrent = ui.scheduler.GetMaxConcurrent()
}
return config
}
// floatPtr is a helper function to create float64 pointers
func floatPtr(f float64) *float64 {
return &f
}
// RegisterUITempl registers the erasure coding templ UI provider with the UI registry
func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *EcDetector, scheduler *Scheduler) {
uiProvider := NewUITemplProvider(detector, scheduler)
uiRegistry.RegisterUI(uiProvider)
glog.V(1).Infof("✅ Registered erasure coding task templ UI provider")
}

330
weed/worker/tasks/vacuum/ui_templ.go

@ -1,330 +0,0 @@
package vacuum
import (
"fmt"
"strconv"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/view/components"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/worker/types"
)
// Helper function to format seconds as duration string
func formatDurationFromSeconds(seconds int) string {
d := time.Duration(seconds) * time.Second
return d.String()
}
// Helper functions to convert between seconds and value+unit format
func secondsToValueAndUnit(seconds int) (float64, string) {
if seconds == 0 {
return 0, "minutes"
}
// Try days first
if seconds%(24*3600) == 0 && seconds >= 24*3600 {
return float64(seconds / (24 * 3600)), "days"
}
// Try hours
if seconds%3600 == 0 && seconds >= 3600 {
return float64(seconds / 3600), "hours"
}
// Default to minutes
return float64(seconds / 60), "minutes"
}
func valueAndUnitToSeconds(value float64, unit string) int {
switch unit {
case "days":
return int(value * 24 * 3600)
case "hours":
return int(value * 3600)
case "minutes":
return int(value * 60)
default:
return int(value * 60) // Default to minutes
}
}
// UITemplProvider provides the templ-based UI for vacuum task configuration
type UITemplProvider struct {
detector *VacuumDetector
scheduler *VacuumScheduler
}
// NewUITemplProvider creates a new vacuum templ UI provider
func NewUITemplProvider(detector *VacuumDetector, scheduler *VacuumScheduler) *UITemplProvider {
return &UITemplProvider{
detector: detector,
scheduler: scheduler,
}
}
// GetTaskType returns the task type
func (ui *UITemplProvider) GetTaskType() types.TaskType {
return types.TaskTypeVacuum
}
// GetDisplayName returns the human-readable name
func (ui *UITemplProvider) GetDisplayName() string {
return "Volume Vacuum"
}
// GetDescription returns a description of what this task does
func (ui *UITemplProvider) GetDescription() string {
return "Reclaims disk space by removing deleted files from volumes"
}
// GetIcon returns the icon CSS class for this task type
func (ui *UITemplProvider) GetIcon() string {
return "fas fa-broom text-primary"
}
// RenderConfigSections renders the configuration as templ section data
func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) {
config := ui.getCurrentVacuumConfig()
// Detection settings section
detectionSection := components.ConfigSectionData{
Title: "Detection Settings",
Icon: "fas fa-search",
Description: "Configure when vacuum tasks should be triggered",
Fields: []interface{}{
components.CheckboxFieldData{
FormFieldData: components.FormFieldData{
Name: "enabled",
Label: "Enable Vacuum Tasks",
Description: "Whether vacuum tasks should be automatically created",
},
Checked: config.Enabled,
},
components.NumberFieldData{
FormFieldData: components.FormFieldData{
Name: "garbage_threshold",
Label: "Garbage Threshold",
Description: "Trigger vacuum when garbage ratio exceeds this percentage (0.0-1.0)",
Required: true,
},
Value: config.GarbageThreshold,
Step: "0.01",
Min: floatPtr(0.0),
Max: floatPtr(1.0),
},
components.DurationInputFieldData{
FormFieldData: components.FormFieldData{
Name: "scan_interval",
Label: "Scan Interval",
Description: "How often to scan for volumes needing vacuum",
Required: true,
},
Seconds: config.ScanIntervalSeconds,
},
components.DurationInputFieldData{
FormFieldData: components.FormFieldData{
Name: "min_volume_age",
Label: "Minimum Volume Age",
Description: "Only vacuum volumes older than this duration",
Required: true,
},
Seconds: config.MinVolumeAgeSeconds,
},
},
}
// Scheduling settings section
schedulingSection := components.ConfigSectionData{
Title: "Scheduling Settings",
Icon: "fas fa-clock",
Description: "Configure task scheduling and concurrency",
Fields: []interface{}{
components.NumberFieldData{
FormFieldData: components.FormFieldData{
Name: "max_concurrent",
Label: "Max Concurrent Tasks",
Description: "Maximum number of vacuum tasks that can run simultaneously",
Required: true,
},
Value: float64(config.MaxConcurrent),
Step: "1",
Min: floatPtr(1),
},
components.DurationInputFieldData{
FormFieldData: components.FormFieldData{
Name: "min_interval",
Label: "Minimum Interval",
Description: "Minimum time between vacuum operations on the same volume",
Required: true,
},
Seconds: config.MinIntervalSeconds,
},
},
}
// Performance impact info section
performanceSection := components.ConfigSectionData{
Title: "Performance Impact",
Icon: "fas fa-exclamation-triangle",
Description: "Important information about vacuum operations",
Fields: []interface{}{
components.TextFieldData{
FormFieldData: components.FormFieldData{
Name: "info_impact",
Label: "Impact",
Description: "Volume vacuum operations are I/O intensive and should be scheduled appropriately",
},
Value: "Configure thresholds and intervals based on your storage usage patterns",
},
},
}
return []components.ConfigSectionData{detectionSection, schedulingSection, performanceSection}, nil
}
// ParseConfigForm parses form data into configuration
func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) {
config := &VacuumConfig{}
// Parse enabled checkbox
config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on"
// Parse garbage threshold
if thresholdStr := formData["garbage_threshold"]; len(thresholdStr) > 0 {
if threshold, err := strconv.ParseFloat(thresholdStr[0], 64); err != nil {
return nil, fmt.Errorf("invalid garbage threshold: %v", err)
} else if threshold < 0 || threshold > 1 {
return nil, fmt.Errorf("garbage threshold must be between 0.0 and 1.0")
} else {
config.GarbageThreshold = threshold
}
}
// Parse scan interval
if valueStr := formData["scan_interval"]; len(valueStr) > 0 {
if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
return nil, fmt.Errorf("invalid scan interval value: %v", err)
} else {
unit := "minutes" // default
if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 {
unit = unitStr[0]
}
config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit)
}
}
// Parse min volume age
if valueStr := formData["min_volume_age"]; len(valueStr) > 0 {
if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
return nil, fmt.Errorf("invalid min volume age value: %v", err)
} else {
unit := "minutes" // default
if unitStr := formData["min_volume_age_unit"]; len(unitStr) > 0 {
unit = unitStr[0]
}
config.MinVolumeAgeSeconds = valueAndUnitToSeconds(value, unit)
}
}
// Parse max concurrent
if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 {
if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil {
return nil, fmt.Errorf("invalid max concurrent: %v", err)
} else if concurrent < 1 {
return nil, fmt.Errorf("max concurrent must be at least 1")
} else {
config.MaxConcurrent = concurrent
}
}
// Parse min interval
if valueStr := formData["min_interval"]; len(valueStr) > 0 {
if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
return nil, fmt.Errorf("invalid min interval value: %v", err)
} else {
unit := "minutes" // default
if unitStr := formData["min_interval_unit"]; len(unitStr) > 0 {
unit = unitStr[0]
}
config.MinIntervalSeconds = valueAndUnitToSeconds(value, unit)
}
}
return config, nil
}
// GetCurrentConfig returns the current configuration
func (ui *UITemplProvider) GetCurrentConfig() interface{} {
return ui.getCurrentVacuumConfig()
}
// ApplyConfig applies the new configuration
func (ui *UITemplProvider) ApplyConfig(config interface{}) error {
vacuumConfig, ok := config.(*VacuumConfig)
if !ok {
return fmt.Errorf("invalid config type, expected *VacuumConfig")
}
// Apply to detector
if ui.detector != nil {
ui.detector.SetEnabled(vacuumConfig.Enabled)
ui.detector.SetGarbageThreshold(vacuumConfig.GarbageThreshold)
ui.detector.SetScanInterval(time.Duration(vacuumConfig.ScanIntervalSeconds) * time.Second)
ui.detector.SetMinVolumeAge(time.Duration(vacuumConfig.MinVolumeAgeSeconds) * time.Second)
}
// Apply to scheduler
if ui.scheduler != nil {
ui.scheduler.SetEnabled(vacuumConfig.Enabled)
ui.scheduler.SetMaxConcurrent(vacuumConfig.MaxConcurrent)
ui.scheduler.SetMinInterval(time.Duration(vacuumConfig.MinIntervalSeconds) * time.Second)
}
glog.V(1).Infof("Applied vacuum configuration: enabled=%v, threshold=%.1f%%, scan_interval=%s, max_concurrent=%d",
vacuumConfig.Enabled, vacuumConfig.GarbageThreshold*100, formatDurationFromSeconds(vacuumConfig.ScanIntervalSeconds), vacuumConfig.MaxConcurrent)
return nil
}
// getCurrentVacuumConfig gets the current configuration from detector and scheduler
func (ui *UITemplProvider) getCurrentVacuumConfig() *VacuumConfig {
config := &VacuumConfig{
// Default values (fallback if detectors/schedulers are nil)
Enabled: true,
GarbageThreshold: 0.3,
ScanIntervalSeconds: int((30 * time.Minute).Seconds()),
MinVolumeAgeSeconds: int((1 * time.Hour).Seconds()),
MaxConcurrent: 2,
MinIntervalSeconds: int((6 * time.Hour).Seconds()),
}
// Get current values from detector
if ui.detector != nil {
config.Enabled = ui.detector.IsEnabled()
config.GarbageThreshold = ui.detector.GetGarbageThreshold()
config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds())
config.MinVolumeAgeSeconds = int(ui.detector.GetMinVolumeAge().Seconds())
}
// Get current values from scheduler
if ui.scheduler != nil {
config.MaxConcurrent = ui.scheduler.GetMaxConcurrent()
config.MinIntervalSeconds = int(ui.scheduler.GetMinInterval().Seconds())
}
return config
}
// floatPtr is a helper function to create float64 pointers
func floatPtr(f float64) *float64 {
return &f
}
// RegisterUITempl registers the vacuum templ UI provider with the UI registry
func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *VacuumDetector, scheduler *VacuumScheduler) {
uiProvider := NewUITemplProvider(detector, scheduler)
uiRegistry.RegisterUI(uiProvider)
glog.V(1).Infof("✅ Registered vacuum task templ UI provider")
}

63
weed/worker/types/task_ui_templ.go

@ -1,63 +0,0 @@
package types
import (
"github.com/seaweedfs/seaweedfs/weed/admin/view/components"
)
// TaskUITemplProvider defines how tasks provide their configuration UI using templ components
type TaskUITemplProvider interface {
// GetTaskType returns the task type
GetTaskType() TaskType
// GetDisplayName returns the human-readable name
GetDisplayName() string
// GetDescription returns a description of what this task does
GetDescription() string
// GetIcon returns the icon CSS class or HTML for this task type
GetIcon() string
// RenderConfigSections renders the configuration as templ section data
RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error)
// ParseConfigForm parses form data into configuration
ParseConfigForm(formData map[string][]string) (interface{}, error)
// GetCurrentConfig returns the current configuration
GetCurrentConfig() interface{}
// ApplyConfig applies the new configuration
ApplyConfig(config interface{}) error
}
// UITemplRegistry manages task UI providers that use templ components
type UITemplRegistry struct {
providers map[TaskType]TaskUITemplProvider
}
// NewUITemplRegistry creates a new templ-based UI registry
func NewUITemplRegistry() *UITemplRegistry {
return &UITemplRegistry{
providers: make(map[TaskType]TaskUITemplProvider),
}
}
// RegisterUI registers a task UI provider
func (r *UITemplRegistry) RegisterUI(provider TaskUITemplProvider) {
r.providers[provider.GetTaskType()] = provider
}
// GetProvider returns the UI provider for a task type
func (r *UITemplRegistry) GetProvider(taskType TaskType) TaskUITemplProvider {
return r.providers[taskType]
}
// GetAllProviders returns all registered UI providers
func (r *UITemplRegistry) GetAllProviders() map[TaskType]TaskUITemplProvider {
result := make(map[TaskType]TaskUITemplProvider)
for k, v := range r.providers {
result[k] = v
}
return result
}
Loading…
Cancel
Save