add feature license management
This commit is contained in:
Binary file not shown.
@@ -65,12 +65,13 @@ func main() {
|
||||
r := router.NewRouter(cfg, db, logger)
|
||||
|
||||
// Create HTTP server
|
||||
// Note: WriteTimeout should be 0 for WebSocket connections (they handle their own timeouts)
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
Handler: r,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
WriteTimeout: 0, // 0 means no timeout - needed for WebSocket connections
|
||||
IdleTimeout: 120 * time.Second, // Increased for WebSocket keep-alive
|
||||
}
|
||||
|
||||
// Setup graceful shutdown
|
||||
|
||||
@@ -23,6 +23,7 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
|
||||
@@ -6,6 +6,8 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -13,24 +13,30 @@ import (
|
||||
// authMiddleware validates JWT tokens and sets user context
|
||||
func authMiddleware(authHandler *auth.Handler) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Extract token from Authorization header
|
||||
var token string
|
||||
|
||||
// Try to extract token from Authorization header first
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
|
||||
if authHeader != "" {
|
||||
// Parse Bearer token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
token = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// If no token from header, try query parameter (for WebSocket)
|
||||
if token == "" {
|
||||
token = c.Query("token")
|
||||
}
|
||||
|
||||
// If still no token, return error
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Parse Bearer token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header format"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
|
||||
// Validate token and get user
|
||||
user, err := authHandler.ValidateToken(token)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/atlasos/calypso/internal/iam"
|
||||
"github.com/atlasos/calypso/internal/monitoring"
|
||||
"github.com/atlasos/calypso/internal/scst"
|
||||
"github.com/atlasos/calypso/internal/shares"
|
||||
"github.com/atlasos/calypso/internal/storage"
|
||||
"github.com/atlasos/calypso/internal/system"
|
||||
"github.com/atlasos/calypso/internal/tape_physical"
|
||||
@@ -198,6 +199,18 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
storageGroup.GET("/zfs/arc/stats", storageHandler.GetARCStats)
|
||||
}
|
||||
|
||||
// Shares (CIFS/NFS)
|
||||
sharesHandler := shares.NewHandler(db, log)
|
||||
sharesGroup := protected.Group("/shares")
|
||||
sharesGroup.Use(requirePermission("storage", "read"))
|
||||
{
|
||||
sharesGroup.GET("", sharesHandler.ListShares)
|
||||
sharesGroup.GET("/:id", sharesHandler.GetShare)
|
||||
sharesGroup.POST("", requirePermission("storage", "write"), sharesHandler.CreateShare)
|
||||
sharesGroup.PUT("/:id", requirePermission("storage", "write"), sharesHandler.UpdateShare)
|
||||
sharesGroup.DELETE("/:id", requirePermission("storage", "write"), sharesHandler.DeleteShare)
|
||||
}
|
||||
|
||||
// SCST
|
||||
scstHandler := scst.NewHandler(db, log)
|
||||
scstGroup := protected.Group("/scst")
|
||||
@@ -232,6 +245,9 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
scstGroup.PUT("/initiator-groups/:id", requirePermission("iscsi", "write"), scstHandler.UpdateInitiatorGroup)
|
||||
scstGroup.DELETE("/initiator-groups/:id", requirePermission("iscsi", "write"), scstHandler.DeleteInitiatorGroup)
|
||||
scstGroup.POST("/initiator-groups/:id/initiators", requirePermission("iscsi", "write"), scstHandler.AddInitiatorToGroup)
|
||||
// Config file management
|
||||
scstGroup.GET("/config/file", requirePermission("iscsi", "read"), scstHandler.GetConfigFile)
|
||||
scstGroup.PUT("/config/file", requirePermission("iscsi", "write"), scstHandler.UpdateConfigFile)
|
||||
}
|
||||
|
||||
// Physical Tape Libraries
|
||||
@@ -295,6 +311,7 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
systemGroup.PUT("/interfaces/:name", systemHandler.UpdateNetworkInterface)
|
||||
systemGroup.GET("/ntp", systemHandler.GetNTPSettings)
|
||||
systemGroup.POST("/ntp", systemHandler.SaveNTPSettings)
|
||||
systemGroup.POST("/execute", requirePermission("system", "write"), systemHandler.ExecuteCommand)
|
||||
}
|
||||
|
||||
// IAM routes - GetUser can be accessed by user viewing own profile or admin
|
||||
|
||||
@@ -745,3 +745,49 @@ func (h *Handler) ListAllInitiatorGroups(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
|
||||
// GetConfigFile reads the SCST configuration file content
|
||||
func (h *Handler) GetConfigFile(c *gin.Context) {
|
||||
configPath := c.DefaultQuery("path", "/etc/scst.conf")
|
||||
|
||||
content, err := h.service.ReadConfigFile(c.Request.Context(), configPath)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to read config file", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"content": content,
|
||||
"path": configPath,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateConfigFile writes content to SCST configuration file
|
||||
func (h *Handler) UpdateConfigFile(c *gin.Context) {
|
||||
var req struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
configPath := req.Path
|
||||
if configPath == "" {
|
||||
configPath = "/etc/scst.conf"
|
||||
}
|
||||
|
||||
if err := h.service.WriteConfigFile(c.Request.Context(), configPath, req.Content); err != nil {
|
||||
h.logger.Error("Failed to write config file", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Configuration file updated successfully",
|
||||
"path": configPath,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1830,6 +1830,59 @@ func (s *Service) WriteConfig(ctx context.Context, configPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadConfigFile reads the SCST configuration file content
|
||||
func (s *Service) ReadConfigFile(ctx context.Context, configPath string) (string, error) {
|
||||
// First, write current config to temp file to get the actual config
|
||||
tempPath := "/tmp/scst_config_read.conf"
|
||||
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-write_config", tempPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to write SCST config: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
// Read the config file
|
||||
configData, err := os.ReadFile(tempPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
return string(configData), nil
|
||||
}
|
||||
|
||||
// WriteConfigFile writes content to SCST configuration file
|
||||
func (s *Service) WriteConfigFile(ctx context.Context, configPath string, content string) error {
|
||||
// Write content to temp file first
|
||||
tempPath := "/tmp/scst_config_write.conf"
|
||||
if err := os.WriteFile(tempPath, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write temp config file: %w", err)
|
||||
}
|
||||
|
||||
// Use scstadmin to load the config (this validates and applies it)
|
||||
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-config", tempPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load SCST config: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
// Write to the actual config path using sudo
|
||||
if configPath != tempPath {
|
||||
// Use sudo cp to copy temp file to actual config path
|
||||
cpCmd := exec.CommandContext(ctx, "sudo", "cp", tempPath, configPath)
|
||||
cpOutput, cpErr := cpCmd.CombinedOutput()
|
||||
if cpErr != nil {
|
||||
return fmt.Errorf("failed to copy config file: %s: %w", string(cpOutput), cpErr)
|
||||
}
|
||||
// Set proper permissions
|
||||
chmodCmd := exec.CommandContext(ctx, "sudo", "chmod", "644", configPath)
|
||||
if chmodErr := chmodCmd.Run(); chmodErr != nil {
|
||||
s.logger.Warn("Failed to set config file permissions", "error", chmodErr)
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("SCST configuration file written", "path", configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandlerInfo represents SCST handler information
|
||||
type HandlerInfo struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
147
backend/internal/shares/handler.go
Normal file
147
backend/internal/shares/handler.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package shares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// Handler handles Shares-related API requests
|
||||
type Handler struct {
|
||||
service *Service
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewHandler creates a new Shares handler
|
||||
func NewHandler(db *database.DB, log *logger.Logger) *Handler {
|
||||
return &Handler{
|
||||
service: NewService(db, log),
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
// ListShares lists all shares
|
||||
func (h *Handler) ListShares(c *gin.Context) {
|
||||
shares, err := h.service.ListShares(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list shares", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list shares"})
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure we return an empty array instead of null
|
||||
if shares == nil {
|
||||
shares = []*Share{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"shares": shares})
|
||||
}
|
||||
|
||||
// GetShare retrieves a share by ID
|
||||
func (h *Handler) GetShare(c *gin.Context) {
|
||||
shareID := c.Param("id")
|
||||
|
||||
share, err := h.service.GetShare(c.Request.Context(), shareID)
|
||||
if err != nil {
|
||||
if err.Error() == "share not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "share not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to get share", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get share"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, share)
|
||||
}
|
||||
|
||||
// CreateShare creates a new share
|
||||
func (h *Handler) CreateShare(c *gin.Context) {
|
||||
var req CreateShareRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid create share request", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
validate := validator.New()
|
||||
if err := validate.Struct(req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID from context (set by auth middleware)
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
share, err := h.service.CreateShare(c.Request.Context(), &req, userID.(string))
|
||||
if err != nil {
|
||||
if err.Error() == "dataset not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "dataset not found"})
|
||||
return
|
||||
}
|
||||
if err.Error() == "only filesystem datasets can be shared (not volumes)" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err.Error() == "at least one protocol (NFS or SMB) must be enabled" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to create share", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, share)
|
||||
}
|
||||
|
||||
// UpdateShare updates an existing share
|
||||
func (h *Handler) UpdateShare(c *gin.Context) {
|
||||
shareID := c.Param("id")
|
||||
|
||||
var req UpdateShareRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid update share request", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
share, err := h.service.UpdateShare(c.Request.Context(), shareID, &req)
|
||||
if err != nil {
|
||||
if err.Error() == "share not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "share not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to update share", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, share)
|
||||
}
|
||||
|
||||
// DeleteShare deletes a share
|
||||
func (h *Handler) DeleteShare(c *gin.Context) {
|
||||
shareID := c.Param("id")
|
||||
|
||||
err := h.service.DeleteShare(c.Request.Context(), shareID)
|
||||
if err != nil {
|
||||
if err.Error() == "share not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "share not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to delete share", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "share deleted successfully"})
|
||||
}
|
||||
806
backend/internal/shares/service.go
Normal file
806
backend/internal/shares/service.go
Normal file
@@ -0,0 +1,806 @@
|
||||
package shares
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// Service handles Shares (CIFS/NFS) operations
|
||||
type Service struct {
|
||||
db *database.DB
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewService creates a new Shares service
|
||||
func NewService(db *database.DB, log *logger.Logger) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Share represents a filesystem share (NFS/SMB)
|
||||
type Share struct {
|
||||
ID string `json:"id"`
|
||||
DatasetID string `json:"dataset_id"`
|
||||
DatasetName string `json:"dataset_name"`
|
||||
MountPoint string `json:"mount_point"`
|
||||
ShareType string `json:"share_type"` // 'nfs', 'smb', 'both'
|
||||
NFSEnabled bool `json:"nfs_enabled"`
|
||||
NFSOptions string `json:"nfs_options,omitempty"`
|
||||
NFSClients []string `json:"nfs_clients,omitempty"`
|
||||
SMBEnabled bool `json:"smb_enabled"`
|
||||
SMBShareName string `json:"smb_share_name,omitempty"`
|
||||
SMBPath string `json:"smb_path,omitempty"`
|
||||
SMBComment string `json:"smb_comment,omitempty"`
|
||||
SMBGuestOK bool `json:"smb_guest_ok"`
|
||||
SMBReadOnly bool `json:"smb_read_only"`
|
||||
SMBBrowseable bool `json:"smb_browseable"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
// ListShares lists all shares
|
||||
func (s *Service) ListShares(ctx context.Context) ([]*Share, error) {
|
||||
query := `
|
||||
SELECT
|
||||
zs.id, zs.dataset_id, zd.name as dataset_name, zd.mount_point,
|
||||
zs.share_type, zs.nfs_enabled, zs.nfs_options, zs.nfs_clients,
|
||||
zs.smb_enabled, zs.smb_share_name, zs.smb_path, zs.smb_comment,
|
||||
zs.smb_guest_ok, zs.smb_read_only, zs.smb_browseable,
|
||||
zs.is_active, zs.created_at, zs.updated_at, zs.created_by
|
||||
FROM zfs_shares zs
|
||||
JOIN zfs_datasets zd ON zs.dataset_id = zd.id
|
||||
ORDER BY zd.name
|
||||
`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
s.logger.Warn("zfs_shares table does not exist, returning empty list")
|
||||
return []*Share{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to list shares: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var shares []*Share
|
||||
for rows.Next() {
|
||||
var share Share
|
||||
var mountPoint sql.NullString
|
||||
var nfsOptions sql.NullString
|
||||
var smbShareName sql.NullString
|
||||
var smbPath sql.NullString
|
||||
var smbComment sql.NullString
|
||||
var nfsClients []string
|
||||
|
||||
err := rows.Scan(
|
||||
&share.ID, &share.DatasetID, &share.DatasetName, &mountPoint,
|
||||
&share.ShareType, &share.NFSEnabled, &nfsOptions, pq.Array(&nfsClients),
|
||||
&share.SMBEnabled, &smbShareName, &smbPath, &smbComment,
|
||||
&share.SMBGuestOK, &share.SMBReadOnly, &share.SMBBrowseable,
|
||||
&share.IsActive, &share.CreatedAt, &share.UpdatedAt, &share.CreatedBy,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to scan share row", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
share.NFSClients = nfsClients
|
||||
|
||||
if mountPoint.Valid {
|
||||
share.MountPoint = mountPoint.String
|
||||
}
|
||||
if nfsOptions.Valid {
|
||||
share.NFSOptions = nfsOptions.String
|
||||
}
|
||||
if smbShareName.Valid {
|
||||
share.SMBShareName = smbShareName.String
|
||||
}
|
||||
if smbPath.Valid {
|
||||
share.SMBPath = smbPath.String
|
||||
}
|
||||
if smbComment.Valid {
|
||||
share.SMBComment = smbComment.String
|
||||
}
|
||||
|
||||
shares = append(shares, &share)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating share rows: %w", err)
|
||||
}
|
||||
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
// GetShare retrieves a share by ID
|
||||
func (s *Service) GetShare(ctx context.Context, shareID string) (*Share, error) {
|
||||
query := `
|
||||
SELECT
|
||||
zs.id, zs.dataset_id, zd.name as dataset_name, zd.mount_point,
|
||||
zs.share_type, zs.nfs_enabled, zs.nfs_options, zs.nfs_clients,
|
||||
zs.smb_enabled, zs.smb_share_name, zs.smb_path, zs.smb_comment,
|
||||
zs.smb_guest_ok, zs.smb_read_only, zs.smb_browseable,
|
||||
zs.is_active, zs.created_at, zs.updated_at, zs.created_by
|
||||
FROM zfs_shares zs
|
||||
JOIN zfs_datasets zd ON zs.dataset_id = zd.id
|
||||
WHERE zs.id = $1
|
||||
`
|
||||
|
||||
var share Share
|
||||
var mountPoint sql.NullString
|
||||
var nfsOptions sql.NullString
|
||||
var smbShareName sql.NullString
|
||||
var smbPath sql.NullString
|
||||
var smbComment sql.NullString
|
||||
var nfsClients []string
|
||||
|
||||
err := s.db.QueryRowContext(ctx, query, shareID).Scan(
|
||||
&share.ID, &share.DatasetID, &share.DatasetName, &mountPoint,
|
||||
&share.ShareType, &share.NFSEnabled, &nfsOptions, pq.Array(&nfsClients),
|
||||
&share.SMBEnabled, &smbShareName, &smbPath, &smbComment,
|
||||
&share.SMBGuestOK, &share.SMBReadOnly, &share.SMBBrowseable,
|
||||
&share.IsActive, &share.CreatedAt, &share.UpdatedAt, &share.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("share not found")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get share: %w", err)
|
||||
}
|
||||
|
||||
share.NFSClients = nfsClients
|
||||
|
||||
if mountPoint.Valid {
|
||||
share.MountPoint = mountPoint.String
|
||||
}
|
||||
if nfsOptions.Valid {
|
||||
share.NFSOptions = nfsOptions.String
|
||||
}
|
||||
if smbShareName.Valid {
|
||||
share.SMBShareName = smbShareName.String
|
||||
}
|
||||
if smbPath.Valid {
|
||||
share.SMBPath = smbPath.String
|
||||
}
|
||||
if smbComment.Valid {
|
||||
share.SMBComment = smbComment.String
|
||||
}
|
||||
|
||||
return &share, nil
|
||||
}
|
||||
|
||||
// CreateShareRequest represents a share creation request
|
||||
type CreateShareRequest struct {
|
||||
DatasetID string `json:"dataset_id" binding:"required"`
|
||||
NFSEnabled bool `json:"nfs_enabled"`
|
||||
NFSOptions string `json:"nfs_options"`
|
||||
NFSClients []string `json:"nfs_clients"`
|
||||
SMBEnabled bool `json:"smb_enabled"`
|
||||
SMBShareName string `json:"smb_share_name"`
|
||||
SMBPath string `json:"smb_path"`
|
||||
SMBComment string `json:"smb_comment"`
|
||||
SMBGuestOK bool `json:"smb_guest_ok"`
|
||||
SMBReadOnly bool `json:"smb_read_only"`
|
||||
SMBBrowseable bool `json:"smb_browseable"`
|
||||
}
|
||||
|
||||
// CreateShare creates a new share
|
||||
func (s *Service) CreateShare(ctx context.Context, req *CreateShareRequest, userID string) (*Share, error) {
|
||||
// Validate dataset exists and is a filesystem (not volume)
|
||||
// req.DatasetID can be either UUID or dataset name
|
||||
var datasetID, datasetType, datasetName, mountPoint string
|
||||
var mountPointNull sql.NullString
|
||||
|
||||
// Try to find by ID first (UUID)
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
"SELECT id, type, name, mount_point FROM zfs_datasets WHERE id = $1",
|
||||
req.DatasetID,
|
||||
).Scan(&datasetID, &datasetType, &datasetName, &mountPointNull)
|
||||
|
||||
// If not found by ID, try by name
|
||||
if err == sql.ErrNoRows {
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
"SELECT id, type, name, mount_point FROM zfs_datasets WHERE name = $1",
|
||||
req.DatasetID,
|
||||
).Scan(&datasetID, &datasetType, &datasetName, &mountPointNull)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("dataset not found")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to validate dataset: %w", err)
|
||||
}
|
||||
|
||||
if mountPointNull.Valid {
|
||||
mountPoint = mountPointNull.String
|
||||
} else {
|
||||
mountPoint = "none"
|
||||
}
|
||||
|
||||
if datasetType != "filesystem" {
|
||||
return nil, fmt.Errorf("only filesystem datasets can be shared (not volumes)")
|
||||
}
|
||||
|
||||
// Determine share type
|
||||
shareType := "none"
|
||||
if req.NFSEnabled && req.SMBEnabled {
|
||||
shareType = "both"
|
||||
} else if req.NFSEnabled {
|
||||
shareType = "nfs"
|
||||
} else if req.SMBEnabled {
|
||||
shareType = "smb"
|
||||
} else {
|
||||
return nil, fmt.Errorf("at least one protocol (NFS or SMB) must be enabled")
|
||||
}
|
||||
|
||||
// Set default NFS options if not provided
|
||||
nfsOptions := req.NFSOptions
|
||||
if nfsOptions == "" {
|
||||
nfsOptions = "rw,sync,no_subtree_check"
|
||||
}
|
||||
|
||||
// Set default SMB share name if not provided
|
||||
smbShareName := req.SMBShareName
|
||||
if smbShareName == "" {
|
||||
// Extract dataset name from full path (e.g., "pool/dataset" -> "dataset")
|
||||
parts := strings.Split(datasetName, "/")
|
||||
smbShareName = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// Set SMB path (use mount_point if available, otherwise use dataset name)
|
||||
smbPath := req.SMBPath
|
||||
if smbPath == "" {
|
||||
if mountPoint != "" && mountPoint != "none" {
|
||||
smbPath = mountPoint
|
||||
} else {
|
||||
smbPath = fmt.Sprintf("/mnt/%s", strings.ReplaceAll(datasetName, "/", "_"))
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
query := `
|
||||
INSERT INTO zfs_shares (
|
||||
dataset_id, share_type, nfs_enabled, nfs_options, nfs_clients,
|
||||
smb_enabled, smb_share_name, smb_path, smb_comment,
|
||||
smb_guest_ok, smb_read_only, smb_browseable, is_active, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
var shareID string
|
||||
var createdAt, updatedAt time.Time
|
||||
|
||||
// Handle nfs_clients array - use empty array if nil
|
||||
nfsClients := req.NFSClients
|
||||
if nfsClients == nil {
|
||||
nfsClients = []string{}
|
||||
}
|
||||
|
||||
err = s.db.QueryRowContext(ctx, query,
|
||||
datasetID, shareType, req.NFSEnabled, nfsOptions, pq.Array(nfsClients),
|
||||
req.SMBEnabled, smbShareName, smbPath, req.SMBComment,
|
||||
req.SMBGuestOK, req.SMBReadOnly, req.SMBBrowseable, true, userID,
|
||||
).Scan(&shareID, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create share: %w", err)
|
||||
}
|
||||
|
||||
// Apply NFS export if enabled
|
||||
if req.NFSEnabled {
|
||||
if err := s.applyNFSExport(ctx, mountPoint, nfsOptions, req.NFSClients); err != nil {
|
||||
s.logger.Error("Failed to apply NFS export", "error", err, "share_id", shareID)
|
||||
// Don't fail the creation, but log the error
|
||||
}
|
||||
}
|
||||
|
||||
// Apply SMB share if enabled
|
||||
if req.SMBEnabled {
|
||||
if err := s.applySMBShare(ctx, smbShareName, smbPath, req.SMBComment, req.SMBGuestOK, req.SMBReadOnly, req.SMBBrowseable); err != nil {
|
||||
s.logger.Error("Failed to apply SMB share", "error", err, "share_id", shareID)
|
||||
// Don't fail the creation, but log the error
|
||||
}
|
||||
}
|
||||
|
||||
// Return the created share
|
||||
return s.GetShare(ctx, shareID)
|
||||
}
|
||||
|
||||
// UpdateShareRequest represents a share update request
|
||||
type UpdateShareRequest struct {
|
||||
NFSEnabled *bool `json:"nfs_enabled"`
|
||||
NFSOptions *string `json:"nfs_options"`
|
||||
NFSClients *[]string `json:"nfs_clients"`
|
||||
SMBEnabled *bool `json:"smb_enabled"`
|
||||
SMBShareName *string `json:"smb_share_name"`
|
||||
SMBComment *string `json:"smb_comment"`
|
||||
SMBGuestOK *bool `json:"smb_guest_ok"`
|
||||
SMBReadOnly *bool `json:"smb_read_only"`
|
||||
SMBBrowseable *bool `json:"smb_browseable"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// UpdateShare updates an existing share
|
||||
func (s *Service) UpdateShare(ctx context.Context, shareID string, req *UpdateShareRequest) (*Share, error) {
|
||||
// Get current share
|
||||
share, err := s.GetShare(ctx, shareID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build update query dynamically
|
||||
updates := []string{}
|
||||
args := []interface{}{}
|
||||
argIndex := 1
|
||||
|
||||
if req.NFSEnabled != nil {
|
||||
updates = append(updates, fmt.Sprintf("nfs_enabled = $%d", argIndex))
|
||||
args = append(args, *req.NFSEnabled)
|
||||
argIndex++
|
||||
}
|
||||
if req.NFSOptions != nil {
|
||||
updates = append(updates, fmt.Sprintf("nfs_options = $%d", argIndex))
|
||||
args = append(args, *req.NFSOptions)
|
||||
argIndex++
|
||||
}
|
||||
if req.NFSClients != nil {
|
||||
updates = append(updates, fmt.Sprintf("nfs_clients = $%d", argIndex))
|
||||
args = append(args, pq.Array(*req.NFSClients))
|
||||
argIndex++
|
||||
}
|
||||
if req.SMBEnabled != nil {
|
||||
updates = append(updates, fmt.Sprintf("smb_enabled = $%d", argIndex))
|
||||
args = append(args, *req.SMBEnabled)
|
||||
argIndex++
|
||||
}
|
||||
if req.SMBShareName != nil {
|
||||
updates = append(updates, fmt.Sprintf("smb_share_name = $%d", argIndex))
|
||||
args = append(args, *req.SMBShareName)
|
||||
argIndex++
|
||||
}
|
||||
if req.SMBComment != nil {
|
||||
updates = append(updates, fmt.Sprintf("smb_comment = $%d", argIndex))
|
||||
args = append(args, *req.SMBComment)
|
||||
argIndex++
|
||||
}
|
||||
if req.SMBGuestOK != nil {
|
||||
updates = append(updates, fmt.Sprintf("smb_guest_ok = $%d", argIndex))
|
||||
args = append(args, *req.SMBGuestOK)
|
||||
argIndex++
|
||||
}
|
||||
if req.SMBReadOnly != nil {
|
||||
updates = append(updates, fmt.Sprintf("smb_read_only = $%d", argIndex))
|
||||
args = append(args, *req.SMBReadOnly)
|
||||
argIndex++
|
||||
}
|
||||
if req.SMBBrowseable != nil {
|
||||
updates = append(updates, fmt.Sprintf("smb_browseable = $%d", argIndex))
|
||||
args = append(args, *req.SMBBrowseable)
|
||||
argIndex++
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
updates = append(updates, fmt.Sprintf("is_active = $%d", argIndex))
|
||||
args = append(args, *req.IsActive)
|
||||
argIndex++
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return share, nil // No changes
|
||||
}
|
||||
|
||||
// Update share_type based on enabled protocols
|
||||
nfsEnabled := share.NFSEnabled
|
||||
smbEnabled := share.SMBEnabled
|
||||
if req.NFSEnabled != nil {
|
||||
nfsEnabled = *req.NFSEnabled
|
||||
}
|
||||
if req.SMBEnabled != nil {
|
||||
smbEnabled = *req.SMBEnabled
|
||||
}
|
||||
|
||||
shareType := "none"
|
||||
if nfsEnabled && smbEnabled {
|
||||
shareType = "both"
|
||||
} else if nfsEnabled {
|
||||
shareType = "nfs"
|
||||
} else if smbEnabled {
|
||||
shareType = "smb"
|
||||
}
|
||||
|
||||
updates = append(updates, fmt.Sprintf("share_type = $%d", argIndex))
|
||||
args = append(args, shareType)
|
||||
argIndex++
|
||||
|
||||
updates = append(updates, fmt.Sprintf("updated_at = NOW()"))
|
||||
args = append(args, shareID)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE zfs_shares
|
||||
SET %s
|
||||
WHERE id = $%d
|
||||
`, strings.Join(updates, ", "), argIndex)
|
||||
|
||||
_, err = s.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update share: %w", err)
|
||||
}
|
||||
|
||||
// Re-apply NFS export if NFS is enabled
|
||||
if nfsEnabled {
|
||||
nfsOptions := share.NFSOptions
|
||||
if req.NFSOptions != nil {
|
||||
nfsOptions = *req.NFSOptions
|
||||
}
|
||||
nfsClients := share.NFSClients
|
||||
if req.NFSClients != nil {
|
||||
nfsClients = *req.NFSClients
|
||||
}
|
||||
if err := s.applyNFSExport(ctx, share.MountPoint, nfsOptions, nfsClients); err != nil {
|
||||
s.logger.Error("Failed to apply NFS export", "error", err, "share_id", shareID)
|
||||
}
|
||||
} else {
|
||||
// Remove NFS export if disabled
|
||||
if err := s.removeNFSExport(ctx, share.MountPoint); err != nil {
|
||||
s.logger.Error("Failed to remove NFS export", "error", err, "share_id", shareID)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-apply SMB share if SMB is enabled
|
||||
if smbEnabled {
|
||||
smbShareName := share.SMBShareName
|
||||
if req.SMBShareName != nil {
|
||||
smbShareName = *req.SMBShareName
|
||||
}
|
||||
smbPath := share.SMBPath
|
||||
smbComment := share.SMBComment
|
||||
if req.SMBComment != nil {
|
||||
smbComment = *req.SMBComment
|
||||
}
|
||||
smbGuestOK := share.SMBGuestOK
|
||||
if req.SMBGuestOK != nil {
|
||||
smbGuestOK = *req.SMBGuestOK
|
||||
}
|
||||
smbReadOnly := share.SMBReadOnly
|
||||
if req.SMBReadOnly != nil {
|
||||
smbReadOnly = *req.SMBReadOnly
|
||||
}
|
||||
smbBrowseable := share.SMBBrowseable
|
||||
if req.SMBBrowseable != nil {
|
||||
smbBrowseable = *req.SMBBrowseable
|
||||
}
|
||||
if err := s.applySMBShare(ctx, smbShareName, smbPath, smbComment, smbGuestOK, smbReadOnly, smbBrowseable); err != nil {
|
||||
s.logger.Error("Failed to apply SMB share", "error", err, "share_id", shareID)
|
||||
}
|
||||
} else {
|
||||
// Remove SMB share if disabled
|
||||
if err := s.removeSMBShare(ctx, share.SMBShareName); err != nil {
|
||||
s.logger.Error("Failed to remove SMB share", "error", err, "share_id", shareID)
|
||||
}
|
||||
}
|
||||
|
||||
return s.GetShare(ctx, shareID)
|
||||
}
|
||||
|
||||
// DeleteShare deletes a share
|
||||
func (s *Service) DeleteShare(ctx context.Context, shareID string) error {
|
||||
// Get share to get mount point and share name
|
||||
share, err := s.GetShare(ctx, shareID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove NFS export
|
||||
if share.NFSEnabled {
|
||||
if err := s.removeNFSExport(ctx, share.MountPoint); err != nil {
|
||||
s.logger.Error("Failed to remove NFS export", "error", err, "share_id", shareID)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove SMB share
|
||||
if share.SMBEnabled {
|
||||
if err := s.removeSMBShare(ctx, share.SMBShareName); err != nil {
|
||||
s.logger.Error("Failed to remove SMB share", "error", err, "share_id", shareID)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
_, err = s.db.ExecContext(ctx, "DELETE FROM zfs_shares WHERE id = $1", shareID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete share: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyNFSExport adds or updates an NFS export in /etc/exports
|
||||
func (s *Service) applyNFSExport(ctx context.Context, mountPoint, options string, clients []string) error {
|
||||
if mountPoint == "" || mountPoint == "none" {
|
||||
return fmt.Errorf("mount point is required for NFS export")
|
||||
}
|
||||
|
||||
// Build client list (default to * if empty)
|
||||
clientList := "*"
|
||||
if len(clients) > 0 {
|
||||
clientList = strings.Join(clients, " ")
|
||||
}
|
||||
|
||||
// Build export line
|
||||
exportLine := fmt.Sprintf("%s %s(%s)", mountPoint, clientList, options)
|
||||
|
||||
// Read current /etc/exports
|
||||
exportsPath := "/etc/exports"
|
||||
exportsContent, err := os.ReadFile(exportsPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to read exports file: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(exportsContent), "\n")
|
||||
var newLines []string
|
||||
found := false
|
||||
|
||||
// Check if this mount point already exists
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
newLines = append(newLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this line is for our mount point
|
||||
if strings.HasPrefix(line, mountPoint+" ") {
|
||||
newLines = append(newLines, exportLine)
|
||||
found = true
|
||||
} else {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Add if not found
|
||||
if !found {
|
||||
newLines = append(newLines, exportLine)
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
newContent := strings.Join(newLines, "\n") + "\n"
|
||||
if err := os.WriteFile(exportsPath, []byte(newContent), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write exports file: %w", err)
|
||||
}
|
||||
|
||||
// Apply exports
|
||||
cmd := exec.CommandContext(ctx, "sudo", "exportfs", "-ra")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to apply exports: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
s.logger.Info("NFS export applied", "mount_point", mountPoint, "clients", clientList)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeNFSExport removes an NFS export from /etc/exports
|
||||
func (s *Service) removeNFSExport(ctx context.Context, mountPoint string) error {
|
||||
if mountPoint == "" || mountPoint == "none" {
|
||||
return nil // Nothing to remove
|
||||
}
|
||||
|
||||
exportsPath := "/etc/exports"
|
||||
exportsContent, err := os.ReadFile(exportsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // File doesn't exist, nothing to remove
|
||||
}
|
||||
return fmt.Errorf("failed to read exports file: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(exportsContent), "\n")
|
||||
var newLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
newLines = append(newLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip lines for this mount point
|
||||
if strings.HasPrefix(line, mountPoint+" ") {
|
||||
continue
|
||||
}
|
||||
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
newContent := strings.Join(newLines, "\n")
|
||||
if newContent != "" && !strings.HasSuffix(newContent, "\n") {
|
||||
newContent += "\n"
|
||||
}
|
||||
if err := os.WriteFile(exportsPath, []byte(newContent), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write exports file: %w", err)
|
||||
}
|
||||
|
||||
// Apply exports
|
||||
cmd := exec.CommandContext(ctx, "sudo", "exportfs", "-ra")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to apply exports: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
s.logger.Info("NFS export removed", "mount_point", mountPoint)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySMBShare adds or updates an SMB share in /etc/samba/smb.conf
|
||||
func (s *Service) applySMBShare(ctx context.Context, shareName, path, comment string, guestOK, readOnly, browseable bool) error {
|
||||
if shareName == "" {
|
||||
return fmt.Errorf("SMB share name is required")
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("SMB path is required")
|
||||
}
|
||||
|
||||
smbConfPath := "/etc/samba/smb.conf"
|
||||
smbContent, err := os.ReadFile(smbConfPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read smb.conf: %w", err)
|
||||
}
|
||||
|
||||
// Parse and update smb.conf
|
||||
lines := strings.Split(string(smbContent), "\n")
|
||||
var newLines []string
|
||||
inShare := false
|
||||
shareStart := -1
|
||||
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Check if we're entering our share section
|
||||
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||
sectionName := trimmed[1 : len(trimmed)-1]
|
||||
if sectionName == shareName {
|
||||
inShare = true
|
||||
shareStart = i
|
||||
continue
|
||||
} else if inShare {
|
||||
// We've left our share section, insert the share config here
|
||||
newLines = append(newLines, s.buildSMBShareConfig(shareName, path, comment, guestOK, readOnly, browseable))
|
||||
inShare = false
|
||||
}
|
||||
}
|
||||
|
||||
if inShare {
|
||||
// Skip lines until we find the next section or end of file
|
||||
continue
|
||||
}
|
||||
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
|
||||
// If we were still in the share at the end, add it
|
||||
if inShare {
|
||||
newLines = append(newLines, s.buildSMBShareConfig(shareName, path, comment, guestOK, readOnly, browseable))
|
||||
} else if shareStart == -1 {
|
||||
// Share doesn't exist, add it at the end
|
||||
newLines = append(newLines, "")
|
||||
newLines = append(newLines, s.buildSMBShareConfig(shareName, path, comment, guestOK, readOnly, browseable))
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
newContent := strings.Join(newLines, "\n")
|
||||
if err := os.WriteFile(smbConfPath, []byte(newContent), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write smb.conf: %w", err)
|
||||
}
|
||||
|
||||
// Reload Samba
|
||||
cmd := exec.CommandContext(ctx, "sudo", "systemctl", "reload", "smbd")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
// Try restart if reload fails
|
||||
cmd = exec.CommandContext(ctx, "sudo", "systemctl", "restart", "smbd")
|
||||
if output2, err2 := cmd.CombinedOutput(); err2 != nil {
|
||||
return fmt.Errorf("failed to reload/restart smbd: %s / %s: %w", string(output), string(output2), err2)
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("SMB share applied", "share_name", shareName, "path", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildSMBShareConfig builds the SMB share configuration block
|
||||
func (s *Service) buildSMBShareConfig(shareName, path, comment string, guestOK, readOnly, browseable bool) string {
|
||||
var config []string
|
||||
config = append(config, fmt.Sprintf("[%s]", shareName))
|
||||
if comment != "" {
|
||||
config = append(config, fmt.Sprintf(" comment = %s", comment))
|
||||
}
|
||||
config = append(config, fmt.Sprintf(" path = %s", path))
|
||||
if guestOK {
|
||||
config = append(config, " guest ok = yes")
|
||||
} else {
|
||||
config = append(config, " guest ok = no")
|
||||
}
|
||||
if readOnly {
|
||||
config = append(config, " read only = yes")
|
||||
} else {
|
||||
config = append(config, " read only = no")
|
||||
}
|
||||
if browseable {
|
||||
config = append(config, " browseable = yes")
|
||||
} else {
|
||||
config = append(config, " browseable = no")
|
||||
}
|
||||
return strings.Join(config, "\n")
|
||||
}
|
||||
|
||||
// removeSMBShare removes an SMB share from /etc/samba/smb.conf
|
||||
func (s *Service) removeSMBShare(ctx context.Context, shareName string) error {
|
||||
if shareName == "" {
|
||||
return nil // Nothing to remove
|
||||
}
|
||||
|
||||
smbConfPath := "/etc/samba/smb.conf"
|
||||
smbContent, err := os.ReadFile(smbConfPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // File doesn't exist, nothing to remove
|
||||
}
|
||||
return fmt.Errorf("failed to read smb.conf: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(smbContent), "\n")
|
||||
var newLines []string
|
||||
inShare := false
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Check if we're entering our share section
|
||||
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||
sectionName := trimmed[1 : len(trimmed)-1]
|
||||
if sectionName == shareName {
|
||||
inShare = true
|
||||
continue
|
||||
} else if inShare {
|
||||
// We've left our share section
|
||||
inShare = false
|
||||
}
|
||||
}
|
||||
|
||||
if inShare {
|
||||
// Skip lines in this share section
|
||||
continue
|
||||
}
|
||||
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
newContent := strings.Join(newLines, "\n")
|
||||
if err := os.WriteFile(smbConfPath, []byte(newContent), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write smb.conf: %w", err)
|
||||
}
|
||||
|
||||
// Reload Samba
|
||||
cmd := exec.CommandContext(ctx, "sudo", "systemctl", "reload", "smbd")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
// Try restart if reload fails
|
||||
cmd = exec.CommandContext(ctx, "sudo", "systemctl", "restart", "smbd")
|
||||
if output2, err2 := cmd.CombinedOutput(); err2 != nil {
|
||||
return fmt.Errorf("failed to reload/restart smbd: %s / %s: %w", string(output), string(output2), err2)
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("SMB share removed", "share_name", shareName)
|
||||
return nil
|
||||
}
|
||||
@@ -610,6 +610,7 @@ func (s *ZFSService) AddSpareDisk(ctx context.Context, poolID string, diskPaths
|
||||
|
||||
// ZFSDataset represents a ZFS dataset
|
||||
type ZFSDataset struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Pool string `json:"pool"`
|
||||
Type string `json:"type"` // filesystem, volume, snapshot
|
||||
@@ -628,7 +629,7 @@ type ZFSDataset struct {
|
||||
func (s *ZFSService) ListDatasets(ctx context.Context, poolName string) ([]*ZFSDataset, error) {
|
||||
// Get datasets from database
|
||||
query := `
|
||||
SELECT name, pool_name, type, mount_point,
|
||||
SELECT id, name, pool_name, type, mount_point,
|
||||
used_bytes, available_bytes, referenced_bytes,
|
||||
compression, deduplication, quota, reservation,
|
||||
created_at
|
||||
@@ -654,7 +655,7 @@ func (s *ZFSService) ListDatasets(ctx context.Context, poolName string) ([]*ZFSD
|
||||
var mountPoint sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&ds.Name, &ds.Pool, &ds.Type, &mountPoint,
|
||||
&ds.ID, &ds.Name, &ds.Pool, &ds.Type, &mountPoint,
|
||||
&ds.UsedBytes, &ds.AvailableBytes, &ds.ReferencedBytes,
|
||||
&ds.Compression, &ds.Deduplication, &ds.Quota, &ds.Reservation,
|
||||
&ds.CreatedAt,
|
||||
|
||||
@@ -253,3 +253,30 @@ func (h *Handler) GetNetworkThroughput(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
// ExecuteCommand executes a shell command
|
||||
func (h *Handler) ExecuteCommand(c *gin.Context) {
|
||||
var req struct {
|
||||
Command string `json:"command" binding:"required"`
|
||||
Service string `json:"service,omitempty"` // Optional: system, scst, storage, backup, tape
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid request body", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "command is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Execute command based on service context
|
||||
output, err := h.service.ExecuteCommand(c.Request.Context(), req.Command, req.Service)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to execute command", "error", err, "command", req.Command, "service", req.Service)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
"output": output, // Include output even on error
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"output": output})
|
||||
}
|
||||
|
||||
@@ -871,3 +871,143 @@ func (s *Service) GetNTPSettings(ctx context.Context) (*NTPSettings, error) {
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// ExecuteCommand executes a shell command and returns the output
|
||||
// service parameter is optional and can be: system, scst, storage, backup, tape
|
||||
func (s *Service) ExecuteCommand(ctx context.Context, command string, service string) (string, error) {
|
||||
// Sanitize command - basic security check
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" {
|
||||
return "", fmt.Errorf("command cannot be empty")
|
||||
}
|
||||
|
||||
// Block dangerous commands that could harm the system
|
||||
dangerousCommands := []string{
|
||||
"rm -rf /",
|
||||
"dd if=",
|
||||
":(){ :|:& };:",
|
||||
"mkfs",
|
||||
"fdisk",
|
||||
"parted",
|
||||
"format",
|
||||
"> /dev/sd",
|
||||
"mkfs.ext",
|
||||
"mkfs.xfs",
|
||||
"mkfs.btrfs",
|
||||
"wipefs",
|
||||
}
|
||||
|
||||
commandLower := strings.ToLower(command)
|
||||
for _, dangerous := range dangerousCommands {
|
||||
if strings.Contains(commandLower, dangerous) {
|
||||
return "", fmt.Errorf("command blocked for security reasons")
|
||||
}
|
||||
}
|
||||
|
||||
// Service-specific command handling
|
||||
switch service {
|
||||
case "scst":
|
||||
// Allow SCST admin commands
|
||||
if strings.HasPrefix(command, "scstadmin") {
|
||||
// SCST commands are safe
|
||||
break
|
||||
}
|
||||
case "backup":
|
||||
// Allow bconsole commands
|
||||
if strings.HasPrefix(command, "bconsole") {
|
||||
// Backup console commands are safe
|
||||
break
|
||||
}
|
||||
case "storage":
|
||||
// Allow ZFS and storage commands
|
||||
if strings.HasPrefix(command, "zfs") || strings.HasPrefix(command, "zpool") || strings.HasPrefix(command, "lsblk") {
|
||||
// Storage commands are safe
|
||||
break
|
||||
}
|
||||
case "tape":
|
||||
// Allow tape library commands
|
||||
if strings.HasPrefix(command, "mtx") || strings.HasPrefix(command, "lsscsi") || strings.HasPrefix(command, "sg_") {
|
||||
// Tape commands are safe
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Execute command with timeout (30 seconds)
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check if command already has sudo (reuse commandLower from above)
|
||||
hasSudo := strings.HasPrefix(commandLower, "sudo ")
|
||||
|
||||
// Determine if command needs sudo based on service and command type
|
||||
needsSudo := false
|
||||
|
||||
if !hasSudo {
|
||||
// Commands that typically need sudo
|
||||
sudoCommands := []string{
|
||||
"scstadmin",
|
||||
"systemctl",
|
||||
"zfs",
|
||||
"zpool",
|
||||
"mount",
|
||||
"umount",
|
||||
"ip link",
|
||||
"ip addr",
|
||||
"iptables",
|
||||
"journalctl",
|
||||
}
|
||||
|
||||
for _, sudoCmd := range sudoCommands {
|
||||
if strings.HasPrefix(commandLower, sudoCmd) {
|
||||
needsSudo = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Service-specific sudo requirements
|
||||
switch service {
|
||||
case "scst":
|
||||
// All SCST admin commands need sudo
|
||||
if strings.HasPrefix(commandLower, "scstadmin") {
|
||||
needsSudo = true
|
||||
}
|
||||
case "storage":
|
||||
// ZFS commands typically need sudo
|
||||
if strings.HasPrefix(commandLower, "zfs") || strings.HasPrefix(commandLower, "zpool") {
|
||||
needsSudo = true
|
||||
}
|
||||
case "system":
|
||||
// System commands like systemctl need sudo
|
||||
if strings.HasPrefix(commandLower, "systemctl") || strings.HasPrefix(commandLower, "journalctl") {
|
||||
needsSudo = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build command with or without sudo
|
||||
var cmd *exec.Cmd
|
||||
if needsSudo && !hasSudo {
|
||||
// Use sudo for privileged commands (if not already present)
|
||||
cmd = exec.CommandContext(ctx, "sudo", "sh", "-c", command)
|
||||
} else {
|
||||
// Regular command (or already has sudo)
|
||||
cmd = exec.CommandContext(ctx, "sh", "-c", command)
|
||||
}
|
||||
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
// Return output even if there's an error (some commands return non-zero exit codes)
|
||||
outputStr := string(output)
|
||||
if len(outputStr) > 0 {
|
||||
return outputStr, nil
|
||||
}
|
||||
return "", fmt.Errorf("command execution failed: %w", err)
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
328
backend/internal/system/terminal.go
Normal file
328
backend/internal/system/terminal.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
"github.com/creack/pty"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
// WebSocket timeouts
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// Allow all origins - in production, validate against allowed domains
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
// TerminalSession manages a single terminal session
|
||||
type TerminalSession struct {
|
||||
conn *websocket.Conn
|
||||
pty *os.File
|
||||
cmd *exec.Cmd
|
||||
logger *logger.Logger
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
username string
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// HandleTerminalWebSocket handles WebSocket connection for terminal
|
||||
func HandleTerminalWebSocket(c *gin.Context, log *logger.Logger) {
|
||||
// Verify authentication
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
log.Warn("Terminal WebSocket: unauthorized access", "ip", c.ClientIP())
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
username, _ := c.Get("username")
|
||||
if username == nil {
|
||||
username = userID
|
||||
}
|
||||
|
||||
log.Info("Terminal WebSocket: connection attempt", "username", username, "ip", c.ClientIP())
|
||||
|
||||
// Upgrade connection
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Error("Terminal WebSocket: upgrade failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("Terminal WebSocket: connection upgraded", "username", username)
|
||||
|
||||
// Create session
|
||||
session := &TerminalSession{
|
||||
conn: conn,
|
||||
logger: log,
|
||||
username: username.(string),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start terminal
|
||||
if err := session.startPTY(); err != nil {
|
||||
log.Error("Terminal WebSocket: failed to start PTY", "error", err, "username", username)
|
||||
session.sendError(err.Error())
|
||||
session.close()
|
||||
return
|
||||
}
|
||||
|
||||
// Handle messages and PTY output
|
||||
go session.handleRead()
|
||||
go session.handleWrite()
|
||||
}
|
||||
|
||||
// startPTY starts the PTY session
|
||||
func (s *TerminalSession) startPTY() error {
|
||||
// Get user info
|
||||
currentUser, err := user.Lookup(s.username)
|
||||
if err != nil {
|
||||
// Fallback to current user
|
||||
currentUser, err = user.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Determine shell
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/bash"
|
||||
}
|
||||
|
||||
// Create command
|
||||
s.cmd = exec.Command(shell)
|
||||
s.cmd.Env = append(os.Environ(),
|
||||
"TERM=xterm-256color",
|
||||
"HOME="+currentUser.HomeDir,
|
||||
"USER="+currentUser.Username,
|
||||
"USERNAME="+currentUser.Username,
|
||||
)
|
||||
s.cmd.Dir = currentUser.HomeDir
|
||||
|
||||
// Start PTY
|
||||
ptyFile, err := pty.Start(s.cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.pty = ptyFile
|
||||
|
||||
// Set initial size
|
||||
pty.Setsize(ptyFile, &pty.Winsize{
|
||||
Rows: 24,
|
||||
Cols: 80,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRead handles incoming WebSocket messages
|
||||
func (s *TerminalSession) handleRead() {
|
||||
defer s.close()
|
||||
|
||||
// Set read deadline and pong handler
|
||||
s.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
s.conn.SetPongHandler(func(string) error {
|
||||
s.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
default:
|
||||
messageType, data, err := s.conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
s.logger.Error("Terminal WebSocket: read error", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle binary messages (raw input)
|
||||
if messageType == websocket.BinaryMessage {
|
||||
s.writeToPTY(data)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle text messages (JSON commands)
|
||||
if messageType == websocket.TextMessage {
|
||||
var msg map[string]interface{}
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg["type"] {
|
||||
case "input":
|
||||
if data, ok := msg["data"].(string); ok {
|
||||
s.writeToPTY([]byte(data))
|
||||
}
|
||||
|
||||
case "resize":
|
||||
if cols, ok1 := msg["cols"].(float64); ok1 {
|
||||
if rows, ok2 := msg["rows"].(float64); ok2 {
|
||||
s.resizePTY(uint16(cols), uint16(rows))
|
||||
}
|
||||
}
|
||||
|
||||
case "ping":
|
||||
s.writeWS(websocket.TextMessage, []byte(`{"type":"pong"}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleWrite handles PTY output to WebSocket
|
||||
func (s *TerminalSession) handleWrite() {
|
||||
defer s.close()
|
||||
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Read from PTY and write to WebSocket
|
||||
buffer := make([]byte, 4096)
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Send ping
|
||||
if err := s.writeWS(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
default:
|
||||
// Read from PTY
|
||||
if s.pty != nil {
|
||||
n, err := s.pty.Read(buffer)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
s.logger.Error("Terminal WebSocket: PTY read error", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
// Write binary data to WebSocket
|
||||
if err := s.writeWS(websocket.BinaryMessage, buffer[:n]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeToPTY writes data to PTY
|
||||
func (s *TerminalSession) writeToPTY(data []byte) {
|
||||
s.mu.RLock()
|
||||
closed := s.closed
|
||||
pty := s.pty
|
||||
s.mu.RUnlock()
|
||||
|
||||
if closed || pty == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := pty.Write(data); err != nil {
|
||||
s.logger.Error("Terminal WebSocket: PTY write error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// resizePTY resizes the PTY
|
||||
func (s *TerminalSession) resizePTY(cols, rows uint16) {
|
||||
s.mu.RLock()
|
||||
closed := s.closed
|
||||
ptyFile := s.pty
|
||||
s.mu.RUnlock()
|
||||
|
||||
if closed || ptyFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Use pty.Setsize from package, not method from variable
|
||||
pty.Setsize(ptyFile, &pty.Winsize{
|
||||
Cols: cols,
|
||||
Rows: rows,
|
||||
})
|
||||
}
|
||||
|
||||
// writeWS writes message to WebSocket
|
||||
func (s *TerminalSession) writeWS(messageType int, data []byte) error {
|
||||
s.mu.RLock()
|
||||
closed := s.closed
|
||||
conn := s.conn
|
||||
s.mu.RUnlock()
|
||||
|
||||
if closed || conn == nil {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
|
||||
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
return conn.WriteMessage(messageType, data)
|
||||
}
|
||||
|
||||
// sendError sends error message
|
||||
func (s *TerminalSession) sendError(errMsg string) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "error",
|
||||
"error": errMsg,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
s.writeWS(websocket.TextMessage, data)
|
||||
}
|
||||
|
||||
// close closes the terminal session
|
||||
func (s *TerminalSession) close() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.closed {
|
||||
return
|
||||
}
|
||||
|
||||
s.closed = true
|
||||
close(s.done)
|
||||
|
||||
// Close PTY
|
||||
if s.pty != nil {
|
||||
s.pty.Close()
|
||||
}
|
||||
|
||||
// Kill process
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
s.cmd.Process.Signal(syscall.SIGTERM)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if s.cmd.ProcessState == nil || !s.cmd.ProcessState.Exited() {
|
||||
s.cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
if s.conn != nil {
|
||||
s.conn.Close()
|
||||
}
|
||||
|
||||
s.logger.Info("Terminal WebSocket: session closed", "username", s.username)
|
||||
}
|
||||
117
docs/WEBSOCKET-PROXY-CONFIG.md
Normal file
117
docs/WEBSOCKET-PROXY-CONFIG.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# WebSocket Proxy Configuration
|
||||
|
||||
Untuk terminal console WebSocket berfungsi dengan baik, reverse proxy (Nginx/Apache) perlu dikonfigurasi untuk mendukung WebSocket upgrade.
|
||||
|
||||
## Nginx Configuration
|
||||
|
||||
Tambahkan konfigurasi berikut di Nginx untuk mendukung WebSocket:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name atlas-demo.avt.data-center.id;
|
||||
|
||||
# WebSocket upgrade headers
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
location /api/v1/system/terminal/ws {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket headers
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket timeouts
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Apache Configuration (mod_proxy_wstunnel)
|
||||
|
||||
Jika menggunakan Apache, pastikan mod_proxy_wstunnel diaktifkan:
|
||||
|
||||
```apache
|
||||
LoadModule proxy_module modules/mod_proxy.so
|
||||
LoadModule proxy_http_module modules/mod_proxy_http.so
|
||||
LoadModule proxy_wstunnel_module modules/mod_proxy_wstunnel.so
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName atlas-demo.avt.data-center.id
|
||||
|
||||
# WebSocket endpoint
|
||||
ProxyPass /api/v1/system/terminal/ws ws://localhost:8080/api/v1/system/terminal/ws
|
||||
ProxyPassReverse /api/v1/system/terminal/ws ws://localhost:8080/api/v1/system/terminal/ws
|
||||
|
||||
# Regular API
|
||||
ProxyPass /api http://localhost:8080/api
|
||||
ProxyPassReverse /api http://localhost:8080/api
|
||||
|
||||
# Frontend
|
||||
ProxyPass / http://localhost:3000/
|
||||
ProxyPassReverse / http://localhost:3000/
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
## Testing WebSocket Connection
|
||||
|
||||
Setelah konfigurasi, test dengan:
|
||||
|
||||
```bash
|
||||
# Test WebSocket connection
|
||||
wscat -c wss://atlas-demo.avt.data-center.id/api/v1/system/terminal/ws?token=YOUR_TOKEN
|
||||
```
|
||||
|
||||
atau menggunakan curl:
|
||||
|
||||
```bash
|
||||
curl -i -N \
|
||||
-H "Connection: Upgrade" \
|
||||
-H "Upgrade: websocket" \
|
||||
-H "Sec-WebSocket-Version: 13" \
|
||||
-H "Sec-WebSocket-Key: test" \
|
||||
http://localhost:8080/api/v1/system/terminal/ws?token=YOUR_TOKEN
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **Error: WebSocket connection failed**
|
||||
- Pastikan reverse proxy dikonfigurasi dengan benar
|
||||
- Check log backend untuk error details
|
||||
- Pastikan port 8080 accessible
|
||||
|
||||
2. **Connection closed immediately**
|
||||
- Check WriteTimeout di server config (harus 0 untuk WebSocket)
|
||||
- Check proxy timeouts (harus cukup panjang)
|
||||
|
||||
3. **401 Unauthorized**
|
||||
- Pastikan token valid dan tidak expired
|
||||
- Check authentication middleware
|
||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@@ -9,6 +9,8 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.12.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
@@ -1764,6 +1766,21 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.12.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
|
||||
@@ -12,8 +12,13 @@ import ISCSITargetsPage from '@/pages/ISCSITargets'
|
||||
import ISCSITargetDetailPage from '@/pages/ISCSITargetDetail'
|
||||
import SystemPage from '@/pages/System'
|
||||
import BackupManagementPage from '@/pages/BackupManagement'
|
||||
import TerminalConsolePage from '@/pages/TerminalConsole'
|
||||
import SharesPage from '@/pages/Shares'
|
||||
import IAMPage from '@/pages/IAM'
|
||||
import ProfilePage from '@/pages/Profile'
|
||||
import MonitoringPage from '@/pages/Monitoring'
|
||||
import ObjectStoragePage from '@/pages/ObjectStorage'
|
||||
import SnapshotReplicationPage from '@/pages/SnapshotReplication'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
// Create a client
|
||||
@@ -59,6 +64,11 @@ function App() {
|
||||
<Route path="iscsi" element={<ISCSITargetsPage />} />
|
||||
<Route path="iscsi/:id" element={<ISCSITargetDetailPage />} />
|
||||
<Route path="backup" element={<BackupManagementPage />} />
|
||||
<Route path="shares" element={<SharesPage />} />
|
||||
<Route path="terminal" element={<TerminalConsolePage />} />
|
||||
<Route path="object-storage" element={<ObjectStoragePage />} />
|
||||
<Route path="snapshots" element={<SnapshotReplicationPage />} />
|
||||
<Route path="monitoring" element={<MonitoringPage />} />
|
||||
<Route path="alerts" element={<AlertsPage />} />
|
||||
<Route path="system" element={<SystemPage />} />
|
||||
<Route path="iam" element={<IAMPage />} />
|
||||
|
||||
@@ -300,6 +300,22 @@ export const scstAPI = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Config file management
|
||||
getConfigFile: async (path?: string): Promise<{ content: string; path: string }> => {
|
||||
const response = await apiClient.get('/scst/config/file', {
|
||||
params: path ? { path } : {},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateConfigFile: async (content: string, path?: string): Promise<{ message: string; path: string }> => {
|
||||
const response = await apiClient.put('/scst/config/file', {
|
||||
content,
|
||||
path,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export interface SCSTExtent {
|
||||
|
||||
75
frontend/src/api/shares.ts
Normal file
75
frontend/src/api/shares.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface Share {
|
||||
id: string
|
||||
dataset_id: string
|
||||
dataset_name: string
|
||||
mount_point: string
|
||||
share_type: 'nfs' | 'smb' | 'both' | 'none'
|
||||
nfs_enabled: boolean
|
||||
nfs_options?: string
|
||||
nfs_clients?: string[]
|
||||
smb_enabled: boolean
|
||||
smb_share_name?: string
|
||||
smb_path?: string
|
||||
smb_comment?: string
|
||||
smb_guest_ok: boolean
|
||||
smb_read_only: boolean
|
||||
smb_browseable: boolean
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface CreateShareRequest {
|
||||
dataset_id: string
|
||||
nfs_enabled: boolean
|
||||
nfs_options?: string
|
||||
nfs_clients?: string[]
|
||||
smb_enabled: boolean
|
||||
smb_share_name?: string
|
||||
smb_comment?: string
|
||||
smb_guest_ok?: boolean
|
||||
smb_read_only?: boolean
|
||||
smb_browseable?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateShareRequest {
|
||||
nfs_enabled?: boolean
|
||||
nfs_options?: string
|
||||
nfs_clients?: string[]
|
||||
smb_enabled?: boolean
|
||||
smb_share_name?: string
|
||||
smb_comment?: string
|
||||
smb_guest_ok?: boolean
|
||||
smb_read_only?: boolean
|
||||
smb_browseable?: boolean
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export const sharesAPI = {
|
||||
listShares: async (): Promise<Share[]> => {
|
||||
const response = await apiClient.get<{ shares: Share[] }>('/shares')
|
||||
return response.data.shares || []
|
||||
},
|
||||
|
||||
getShare: async (id: string): Promise<Share> => {
|
||||
const response = await apiClient.get<Share>(`/shares/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createShare: async (data: CreateShareRequest): Promise<Share> => {
|
||||
const response = await apiClient.post<Share>('/shares', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateShare: async (id: string, data: UpdateShareRequest): Promise<Share> => {
|
||||
const response = await apiClient.put<Share>(`/shares/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteShare: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/shares/${id}`)
|
||||
},
|
||||
}
|
||||
@@ -166,6 +166,7 @@ export const zfsApi = {
|
||||
}
|
||||
|
||||
export interface ZFSDataset {
|
||||
id: string
|
||||
name: string
|
||||
pool: string
|
||||
type: string // filesystem, volume, snapshot
|
||||
|
||||
@@ -7,11 +7,15 @@ import {
|
||||
HardDrive,
|
||||
Database,
|
||||
Network,
|
||||
Settings,
|
||||
Bell,
|
||||
Server,
|
||||
Users,
|
||||
Archive
|
||||
Archive,
|
||||
Terminal,
|
||||
Share,
|
||||
Activity,
|
||||
Box,
|
||||
Camera
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
@@ -44,10 +48,14 @@ export default function Layout() {
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Storage', href: '/storage', icon: HardDrive },
|
||||
{ name: 'Object Storage', href: '/object-storage', icon: Box },
|
||||
{ name: 'Shares', href: '/shares', icon: Share },
|
||||
{ name: 'Snapshots & Replication', href: '/snapshots', icon: Camera },
|
||||
{ name: 'Tape Libraries', href: '/tape', icon: Database },
|
||||
{ name: 'iSCSI Management', href: '/iscsi', icon: Network },
|
||||
{ name: 'Backup Management', href: '/backup', icon: Archive },
|
||||
{ name: 'Tasks', href: '/tasks', icon: Settings },
|
||||
{ name: 'Terminal Console', href: '/terminal', icon: Terminal },
|
||||
{ name: 'Monitoring & Logs', href: '/monitoring', icon: Activity },
|
||||
{ name: 'Alerts', href: '/alerts', icon: Bell },
|
||||
{ name: 'System', href: '/system', icon: Server },
|
||||
]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { scstAPI, type SCSTTarget, type SCSTInitiatorGroup, type SCSTPortal, type SCSTInitiator, type SCSTExtent, type CreateExtentRequest } from '@/api/scst'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, Settings, ChevronRight, Search, ChevronLeft, ChevronRight as ChevronRightIcon, CheckCircle, HardDrive, ArrowUpDown, ArrowUp, ChevronUp, ChevronDown, Copy, Network, X, Trash2 } from 'lucide-react'
|
||||
import { Plus, Settings, ChevronRight, Search, ChevronLeft, ChevronRight as ChevronRightIcon, CheckCircle, HardDrive, ArrowUpDown, ArrowUp, ChevronUp, ChevronDown, Copy, Network, X, Trash2, Save, RefreshCw, Terminal } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function ISCSITargets() {
|
||||
@@ -179,6 +179,19 @@ export default function ISCSITargets() {
|
||||
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('config')}
|
||||
className={`relative py-4 text-sm tracking-wide transition-colors ${
|
||||
activeTab === 'config'
|
||||
? 'text-primary font-bold'
|
||||
: 'text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
Config Editor
|
||||
{activeTab === 'config' && (
|
||||
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -275,6 +288,10 @@ export default function ISCSITargets() {
|
||||
{activeTab === 'groups' && (
|
||||
<InitiatorGroupsTab />
|
||||
)}
|
||||
|
||||
{activeTab === 'config' && (
|
||||
<ConfigEditorTab />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2310,3 +2327,156 @@ function AddInitiatorToGroupModal({ groupName, onClose, isLoading, onSubmit }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Config Editor Tab Component
|
||||
function ConfigEditorTab() {
|
||||
const [content, setContent] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [originalContent, setOriginalContent] = useState('')
|
||||
const [configPath, setConfigPath] = useState('/etc/scst.conf')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const { data: configData, refetch: refetchConfig, isFetching } = useQuery<{ content: string; path: string }>({
|
||||
queryKey: ['scst-config-file'],
|
||||
queryFn: () => scstAPI.getConfigFile(),
|
||||
})
|
||||
|
||||
// Handle config data changes
|
||||
useEffect(() => {
|
||||
if (configData) {
|
||||
setContent(configData.content)
|
||||
setOriginalContent(configData.content)
|
||||
setConfigPath(configData.path)
|
||||
setHasChanges(false)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [configData])
|
||||
|
||||
// Handle loading state
|
||||
useEffect(() => {
|
||||
if (isFetching) {
|
||||
setIsLoading(true)
|
||||
} else if (configData) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [isFetching, configData])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (content: string) => scstAPI.updateConfigFile(content),
|
||||
onSuccess: () => {
|
||||
setOriginalContent(content)
|
||||
setHasChanges(false)
|
||||
alert('Configuration file saved successfully!')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Failed to save configuration: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
setHasChanges(e.target.value !== originalContent)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!hasChanges) return
|
||||
if (confirm('Save changes to scst.conf? This will update the SCST configuration.')) {
|
||||
saveMutation.mutate(content)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReload = () => {
|
||||
if (hasChanges && !confirm('You have unsaved changes. Reload anyway?')) {
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
refetchConfig()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-[600px] bg-[#0a0f14] border border-border-dark rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex-none px-6 py-4 border-b border-border-dark bg-[#141d26] flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className="text-primary" size={20} />
|
||||
<div>
|
||||
<h3 className="text-white font-bold text-sm">SCST Configuration Editor</h3>
|
||||
<p className="text-text-secondary text-xs mt-0.5">
|
||||
Edit /etc/scst.conf file directly
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReload}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||
<span>Reload</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saveMutation.isPending}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-blue-600"
|
||||
>
|
||||
<Save size={16} />
|
||||
<span>{saveMutation.isPending ? 'Saving...' : 'Save'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-text-secondary">
|
||||
<RefreshCw size={24} className="animate-spin mx-auto mb-2" />
|
||||
<p>Loading configuration...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
className="w-full h-full p-4 bg-[#0a0f14] text-green-400 font-mono text-sm resize-none focus:outline-none focus:ring-0 border-0"
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
lineHeight: '1.6',
|
||||
tabSize: 2,
|
||||
}}
|
||||
spellCheck={false}
|
||||
placeholder="Loading configuration file..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex-none px-6 py-3 border-t border-border-dark bg-[#141d26] flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-4 text-text-secondary">
|
||||
<span>Path: {configPath}</span>
|
||||
{hasChanges && (
|
||||
<span className="text-yellow-400 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-yellow-400 rounded-full"></span>
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-text-secondary">
|
||||
{content.split('\n').length} lines
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
627
frontend/src/pages/Monitoring.tsx
Normal file
627
frontend/src/pages/Monitoring.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitoringApi } from '@/api/monitoring'
|
||||
import { systemAPI } from '@/api/system'
|
||||
import { zfsApi } from '@/api/storage'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import {
|
||||
RefreshCw,
|
||||
TrendingDown,
|
||||
CheckCircle2,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Clock,
|
||||
Search,
|
||||
AlertCircle,
|
||||
Info,
|
||||
AlertTriangle
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
AreaChart,
|
||||
} from 'recharts'
|
||||
|
||||
const MOCK_ACTIVE_JOBS = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Daily Backup: VM-Cluster-01',
|
||||
type: 'Replication',
|
||||
progress: 45,
|
||||
speed: '145 MB/s',
|
||||
status: 'running',
|
||||
eta: '1h 12m',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'ZFS Scrub: Pool-01',
|
||||
type: 'Maintenance',
|
||||
progress: 78,
|
||||
speed: '1.2 GB/s',
|
||||
status: 'running',
|
||||
},
|
||||
]
|
||||
|
||||
export default function Monitoring() {
|
||||
const [activeTab, setActiveTab] = useState<'jobs' | 'logs' | 'alerts'>('jobs')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const refreshInterval = 5
|
||||
|
||||
// Fetch metrics
|
||||
const { data: metrics, isLoading: metricsLoading } = useQuery({
|
||||
queryKey: ['monitoring-metrics'],
|
||||
queryFn: monitoringApi.getMetrics,
|
||||
refetchInterval: refreshInterval * 1000,
|
||||
})
|
||||
|
||||
// Fetch system logs
|
||||
const { data: systemLogs = [], isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['monitoring-logs'],
|
||||
queryFn: () => systemAPI.getSystemLogs(50),
|
||||
refetchInterval: 10 * 1000,
|
||||
})
|
||||
|
||||
// Fetch network throughput
|
||||
const { data: networkData = [] } = useQuery({
|
||||
queryKey: ['monitoring-network'],
|
||||
queryFn: () => systemAPI.getNetworkThroughput('15m'),
|
||||
refetchInterval: refreshInterval * 1000,
|
||||
})
|
||||
|
||||
// Fetch ZFS pools for health status
|
||||
const { data: pools = [] } = useQuery({
|
||||
queryKey: ['monitoring-pools'],
|
||||
queryFn: zfsApi.listPools,
|
||||
refetchInterval: 30 * 1000,
|
||||
})
|
||||
|
||||
// Fetch alerts
|
||||
const { data: alertsData } = useQuery({
|
||||
queryKey: ['monitoring-alerts'],
|
||||
queryFn: () => monitoringApi.listAlerts({ limit: 20 }),
|
||||
refetchInterval: 10 * 1000,
|
||||
})
|
||||
|
||||
// Format uptime
|
||||
const formatUptime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return `${days}d ${hours}h ${minutes}m`
|
||||
}
|
||||
|
||||
// Get ZFS pool health
|
||||
const zfsHealth = pools.length > 0 ? pools[0] : null
|
||||
const zfsStatus = zfsHealth?.health_status === 'online' ? 'Online' : 'Degraded'
|
||||
const zfsHealthy = zfsHealth?.health_status === 'online'
|
||||
|
||||
// Filter logs by search query
|
||||
const filteredLogs = systemLogs.filter(log =>
|
||||
log.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.source.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
// Get log level color
|
||||
const getLogLevelColor = (level: string) => {
|
||||
const upperLevel = level.toUpperCase()
|
||||
if (upperLevel === 'INFO' || upperLevel === 'DEBUG') return 'text-emerald-500'
|
||||
if (upperLevel === 'WARN' || upperLevel === 'WARNING') return 'text-yellow-500'
|
||||
if (upperLevel === 'ERROR' || upperLevel === 'CRITICAL' || upperLevel === 'FATAL') return 'text-red-500'
|
||||
return 'text-text-secondary'
|
||||
}
|
||||
|
||||
// Calculate network peak
|
||||
const networkPeak = networkData.length > 0
|
||||
? Math.max(...networkData.map(d => Math.max(d.inbound, d.outbound)))
|
||||
: 0
|
||||
|
||||
// Calculate current network throughput (convert Mbps to Gbps)
|
||||
const currentThroughput = networkData.length > 0
|
||||
? ((networkData[networkData.length - 1].inbound + networkData[networkData.length - 1].outbound) / 1000).toFixed(1)
|
||||
: '0.0'
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex flex-col bg-background-dark">
|
||||
{/* Header */}
|
||||
<header className="flex-none px-6 py-5 border-b border-border-dark bg-background-dark/95 backdrop-blur z-10">
|
||||
<div className="flex flex-wrap justify-between items-end gap-3 max-w-[1600px] mx-auto">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-white text-3xl font-black tracking-tight">System Monitor</h2>
|
||||
<p className="text-text-secondary text-sm">Real-time telemetry, ZFS health, and system event logs</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-card-dark rounded-lg border border-border-dark">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</span>
|
||||
<span className="text-xs font-medium text-emerald-400">System Healthy</span>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 h-10 px-4 bg-card-dark hover:bg-[#233648] border border-border-dark text-white text-sm font-bold rounded-lg transition-colors">
|
||||
<RefreshCw size={18} />
|
||||
<span>Refresh: {refreshInterval}s</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6">
|
||||
<div className="flex flex-col gap-6 max-w-[1600px] mx-auto pb-10">
|
||||
{/* Top Stats Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* CPU */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">CPU Load</p>
|
||||
<Cpu className="text-text-secondary" size={20} />
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-1">
|
||||
<p className="text-white text-3xl font-bold">
|
||||
{metricsLoading ? '...' : `${metrics?.system?.cpu_usage_percent?.toFixed(0) || 0}%`}
|
||||
</p>
|
||||
<span className="text-emerald-500 text-sm font-medium mb-1 flex items-center">
|
||||
<TrendingDown size={16} className="mr-1" />
|
||||
2%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-[#233648] rounded-full mt-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all"
|
||||
style={{ width: `${metrics?.system?.cpu_usage_percent || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAM */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">RAM Usage</p>
|
||||
<MemoryStick className="text-text-secondary" size={20} />
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-1">
|
||||
<p className="text-white text-3xl font-bold">
|
||||
{metricsLoading ? '...' : formatBytes(metrics?.system?.memory_used_bytes || 0)}
|
||||
</p>
|
||||
<span className="text-text-secondary text-xs mb-2">
|
||||
/ {formatBytes(metrics?.system?.memory_total_bytes || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-[#233648] rounded-full mt-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all"
|
||||
style={{ width: `${metrics?.system?.memory_usage_percent || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ZFS Health */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">ZFS Pool Status</p>
|
||||
<CheckCircle2 className={zfsHealthy ? 'text-emerald-500' : 'text-yellow-500'} size={20} />
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-1">
|
||||
<p className="text-white text-3xl font-bold">{zfsStatus}</p>
|
||||
<span className="text-text-secondary text-sm font-medium mb-1">No Errors</span>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 flex-1 rounded-full ${
|
||||
i === 1 ? 'rounded-l-full' : i === 4 ? 'rounded-r-full' : ''
|
||||
} ${zfsHealthy ? 'bg-emerald-500' : 'bg-yellow-500'}`}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">System Uptime</p>
|
||||
<Clock className="text-text-secondary" size={20} />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<p className="text-white text-3xl font-bold">
|
||||
{metricsLoading ? '...' : formatUptime(metrics?.system?.uptime_seconds || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-text-secondary text-xs mt-3">Last reboot: Manual Patching</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Section: Charts & Disks */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Charts Column (2/3) */}
|
||||
<div className="xl:col-span-2 flex flex-col gap-6">
|
||||
{/* Network Chart */}
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl p-6 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-white text-lg font-bold">Network Throughput</h3>
|
||||
<p className="text-text-secondary text-sm">Inbound vs Outbound (eth0)</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white text-2xl font-bold leading-tight">{currentThroughput} Gbps</p>
|
||||
<p className="text-emerald-500 text-sm">Peak: {(networkPeak / 1000).toFixed(1)} Gbps</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[200px] w-full">
|
||||
{networkData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={networkData.map(d => ({
|
||||
time: new Date(d.time).toLocaleTimeString(),
|
||||
inbound: d.inbound,
|
||||
outbound: d.outbound,
|
||||
}))}>
|
||||
<defs>
|
||||
<linearGradient id="gradientPrimary" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#137fec" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="#137fec" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#324d67" />
|
||||
<XAxis dataKey="time" stroke="#92adc9" style={{ fontSize: '12px' }} />
|
||||
<YAxis stroke="#92adc9" style={{ fontSize: '12px' }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1a2632',
|
||||
border: '1px solid #324d67',
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="outbound"
|
||||
stroke="#92adc9"
|
||||
strokeDasharray="5 5"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="inbound"
|
||||
stroke="#137fec"
|
||||
strokeWidth={3}
|
||||
fill="url(#gradientPrimary)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-text-secondary">
|
||||
Loading network data...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ZFS ARC Chart */}
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl p-6 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-white text-lg font-bold">ZFS ARC Hit Ratio</h3>
|
||||
<p className="text-text-secondary text-sm">Cache efficiency</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white text-2xl font-bold leading-tight">94%</p>
|
||||
<p className="text-text-secondary text-sm">Target: >90%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[150px] w-full relative">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={[
|
||||
{ time: '10:00', ratio: 95 },
|
||||
{ time: '10:15', ratio: 94 },
|
||||
{ time: '10:30', ratio: 96 },
|
||||
{ time: '10:45', ratio: 93 },
|
||||
{ time: '11:00', ratio: 94 },
|
||||
]}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#324d67" />
|
||||
<XAxis dataKey="time" stroke="#92adc9" style={{ fontSize: '12px' }} />
|
||||
<YAxis stroke="#92adc9" domain={[90, 100]} style={{ fontSize: '12px' }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1a2632',
|
||||
border: '1px solid #324d67',
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ratio"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="w-full h-[1px] bg-border-dark absolute top-[20%]"></div>
|
||||
<div className="absolute top-[20%] right-0 text-xs text-text-secondary -mt-5">95%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk Health Column (1/3) */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl p-6 h-full shadow-sm flex flex-col">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-white text-lg font-bold">Disk Health</h3>
|
||||
<span className="bg-[#233648] text-white text-xs px-2 py-1 rounded border border-border-dark">
|
||||
Pool 1
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3 flex-1 content-start">
|
||||
{/* Mock disk health - would be replaced with real data */}
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`aspect-square border rounded flex flex-col items-center justify-center ${
|
||||
i === 5
|
||||
? 'bg-[#332a18] border-yellow-700/50'
|
||||
: 'bg-[#1a2e22] border-emerald-800'
|
||||
}`}
|
||||
>
|
||||
{i === 5 ? (
|
||||
<>
|
||||
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-yellow-500 animate-pulse"></span>
|
||||
<AlertTriangle className="text-yellow-500" size={20} />
|
||||
</>
|
||||
) : (
|
||||
<HardDrive className="text-emerald-500" size={20} />
|
||||
)}
|
||||
<span className={`text-[10px] font-mono mt-1 ${
|
||||
i === 5 ? 'text-yellow-500' : 'text-emerald-500'
|
||||
}`}>
|
||||
da{i}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Empty slots */}
|
||||
{[8, 9, 10, 11].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square bg-[#161f29] border border-border-dark border-dashed rounded flex flex-col items-center justify-center opacity-50"
|
||||
>
|
||||
<span className="text-[10px] text-text-secondary font-mono">Empty</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-border-dark">
|
||||
<div className="flex justify-between text-sm text-text-secondary">
|
||||
<span>Total Capacity</span>
|
||||
<span className="text-white font-bold">
|
||||
{formatBytes(metrics?.storage?.total_capacity_bytes || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#233648] h-2 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full transition-all"
|
||||
style={{
|
||||
width: `${
|
||||
metrics?.storage?.total_capacity_bytes
|
||||
? (metrics.storage.used_capacity_bytes / metrics.storage.total_capacity_bytes) * 100
|
||||
: 0
|
||||
}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-text-secondary mt-1">
|
||||
<span>Used: {formatBytes(metrics?.storage?.used_capacity_bytes || 0)}</span>
|
||||
<span>Free: {formatBytes((metrics?.storage?.total_capacity_bytes || 0) - (metrics?.storage?.used_capacity_bytes || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section: Tabs & Logs */}
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl shadow-sm overflow-hidden flex flex-col h-[400px]">
|
||||
{/* Tabs Header */}
|
||||
<div className="flex border-b border-border-dark bg-[#161f29]">
|
||||
<button
|
||||
onClick={() => setActiveTab('jobs')}
|
||||
className={`px-6 py-4 text-sm font-bold flex items-center transition-colors ${
|
||||
activeTab === 'jobs'
|
||||
? 'text-primary border-b-2 border-primary bg-card-dark'
|
||||
: 'text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Active Jobs{' '}
|
||||
<span className="ml-2 bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs">
|
||||
{MOCK_ACTIVE_JOBS.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('logs')}
|
||||
className={`px-6 py-4 text-sm transition-colors ${
|
||||
activeTab === 'logs'
|
||||
? 'text-primary border-b-2 border-primary bg-card-dark font-bold'
|
||||
: 'text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
System Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('alerts')}
|
||||
className={`px-6 py-4 text-sm transition-colors ${
|
||||
activeTab === 'alerts'
|
||||
? 'text-primary border-b-2 border-primary bg-card-dark font-bold'
|
||||
: 'text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
Alerts History
|
||||
</button>
|
||||
<div className="flex-1 flex justify-end items-center px-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1.5 text-text-secondary" size={18} />
|
||||
<input
|
||||
className="bg-[#111a22] border border-border-dark rounded-md py-1 pl-8 pr-3 text-sm text-white focus:outline-none focus:border-primary w-48 transition-all"
|
||||
placeholder="Search logs..."
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{activeTab === 'jobs' && (
|
||||
<div className="p-0 overflow-y-auto custom-scrollbar">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[#1a2632] text-xs uppercase text-text-secondary font-medium sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Job Name</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Type</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark w-1/3">Progress</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Speed</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm divide-y divide-border-dark">
|
||||
{MOCK_ACTIVE_JOBS.map((job) => (
|
||||
<tr key={job.id} className="group hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-white">{job.name}</td>
|
||||
<td className="px-6 py-4 text-text-secondary">{job.type}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-full bg-[#111a22] rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full relative overflow-hidden"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-white">{job.progress}%</span>
|
||||
</div>
|
||||
{job.eta && (
|
||||
<p className="text-[10px] text-text-secondary mt-1">ETA: {job.eta}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-text-secondary font-mono">{job.speed}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-primary/20 text-primary">
|
||||
Running
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<>
|
||||
{/* Logs Section Header */}
|
||||
<div className="px-6 py-2 bg-[#161f29] border-y border-border-dark flex items-center justify-between">
|
||||
<h4 className="text-xs uppercase text-text-secondary font-bold tracking-wider">
|
||||
Recent System Events
|
||||
</h4>
|
||||
<button className="text-xs text-primary hover:text-white transition-colors">
|
||||
View All Logs
|
||||
</button>
|
||||
</div>
|
||||
{/* Logs Table */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar bg-[#111a22]">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<tbody className="text-sm font-mono divide-y divide-border-dark/50">
|
||||
{logsLoading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-4 text-center text-text-secondary">
|
||||
Loading logs...
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-4 text-center text-text-secondary">
|
||||
No logs found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredLogs.map((log, idx) => (
|
||||
<tr key={idx} className="group hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-2 text-text-secondary w-32 whitespace-nowrap">
|
||||
{new Date(log.time).toLocaleTimeString()}
|
||||
</td>
|
||||
<td className="px-6 py-2 w-24">
|
||||
<span className={getLogLevelColor(log.level)}>{log.level}</span>
|
||||
</td>
|
||||
<td className="px-6 py-2 w-32 text-white">{log.source}</td>
|
||||
<td className="px-6 py-2 text-text-secondary truncate max-w-lg">{log.message}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'alerts' && (
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar bg-[#111a22] p-6">
|
||||
{alertsData?.alerts && alertsData.alerts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{alertsData.alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="bg-[#1a2632] border border-border-dark rounded-lg p-4 hover:bg-[#233648] transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
{alert.severity === 'critical' ? (
|
||||
<AlertCircle className="text-red-500 mt-1" size={20} />
|
||||
) : alert.severity === 'warning' ? (
|
||||
<AlertTriangle className="text-yellow-500 mt-1" size={20} />
|
||||
) : (
|
||||
<Info className="text-blue-500 mt-1" size={20} />
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-white font-medium">{alert.title}</h4>
|
||||
<p className="text-text-secondary text-sm mt-1">{alert.message}</p>
|
||||
<p className="text-text-secondary text-xs mt-2">
|
||||
{new Date(alert.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
alert.severity === 'critical'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: alert.severity === 'warning'
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{alert.severity.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-text-secondary py-8">No alerts</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
475
frontend/src/pages/ObjectStorage.tsx
Normal file
475
frontend/src/pages/ObjectStorage.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import {
|
||||
Folder,
|
||||
Share2,
|
||||
Globe,
|
||||
Search,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
CheckCircle2,
|
||||
HardDrive,
|
||||
Database,
|
||||
Clock,
|
||||
Link as LinkIcon,
|
||||
Copy,
|
||||
FileText,
|
||||
Settings,
|
||||
Users,
|
||||
Activity,
|
||||
Filter
|
||||
} from 'lucide-react'
|
||||
|
||||
// Mock data - will be replaced with API calls
|
||||
const MOCK_BUCKETS = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'backup-archive-01',
|
||||
type: 'immutable',
|
||||
usage: 4.2 * 1024 * 1024 * 1024 * 1024, // 4.2 TB in bytes
|
||||
usagePercent: 75,
|
||||
objects: 14200,
|
||||
accessPolicy: 'private',
|
||||
created: '2023-10-24',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'daily-snapshots',
|
||||
type: 'standard',
|
||||
usage: 120 * 1024 * 1024 * 1024, // 120 GB
|
||||
usagePercent: 15,
|
||||
objects: 400,
|
||||
accessPolicy: 'private',
|
||||
created: '2023-11-01',
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'public-assets',
|
||||
type: 'standard',
|
||||
usage: 500 * 1024 * 1024 * 1024, // 500 GB
|
||||
usagePercent: 30,
|
||||
objects: 12050,
|
||||
accessPolicy: 'public-read',
|
||||
created: '2023-12-15',
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'logs-retention',
|
||||
type: 'archive',
|
||||
usage: 2.1 * 1024 * 1024 * 1024 * 1024, // 2.1 TB
|
||||
usagePercent: 55,
|
||||
objects: 850221,
|
||||
accessPolicy: 'private',
|
||||
created: '2024-01-10',
|
||||
color: 'blue',
|
||||
},
|
||||
]
|
||||
|
||||
const S3_ENDPOINT = 'https://s3.appliance.local:9000'
|
||||
|
||||
export default function ObjectStorage() {
|
||||
const [activeTab, setActiveTab] = useState<'buckets' | 'users' | 'monitoring' | 'settings'>('buckets')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const itemsPerPage = 10
|
||||
|
||||
// Mock queries - replace with real API calls
|
||||
const { data: buckets = MOCK_BUCKETS } = useQuery({
|
||||
queryKey: ['object-storage-buckets'],
|
||||
queryFn: async () => MOCK_BUCKETS,
|
||||
})
|
||||
|
||||
// Filter buckets by search query
|
||||
const filteredBuckets = buckets.filter(bucket =>
|
||||
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(filteredBuckets.length / itemsPerPage)
|
||||
const paginatedBuckets = filteredBuckets.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
)
|
||||
|
||||
// Calculate totals
|
||||
const totalUsage = buckets.reduce((sum, b) => sum + b.usage, 0)
|
||||
const totalObjects = buckets.reduce((sum, b) => sum + b.objects, 0)
|
||||
|
||||
// Copy endpoint to clipboard
|
||||
const copyEndpoint = () => {
|
||||
navigator.clipboard.writeText(S3_ENDPOINT)
|
||||
// You could add a toast notification here
|
||||
}
|
||||
|
||||
// Get bucket icon
|
||||
const getBucketIcon = (bucket: typeof MOCK_BUCKETS[0]) => {
|
||||
if (bucket.accessPolicy === 'public-read') {
|
||||
return <Globe className="text-orange-500" size={20} />
|
||||
}
|
||||
if (bucket.type === 'immutable') {
|
||||
return <Folder className="text-blue-500" size={20} />
|
||||
}
|
||||
return <Share2 className="text-purple-500" size={20} />
|
||||
}
|
||||
|
||||
// Get bucket color class
|
||||
const getBucketColorClass = (color: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
blue: 'bg-blue-500/10 text-blue-500',
|
||||
purple: 'bg-purple-500/10 text-purple-500',
|
||||
orange: 'bg-orange-500/10 text-orange-500',
|
||||
}
|
||||
return colors[color] || colors.blue
|
||||
}
|
||||
|
||||
// Get progress bar color
|
||||
const getProgressColor = (color: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
blue: 'bg-primary',
|
||||
purple: 'bg-purple-500',
|
||||
orange: 'bg-orange-500',
|
||||
}
|
||||
return colors[color] || colors.blue
|
||||
}
|
||||
|
||||
// Get access policy badge
|
||||
const getAccessPolicyBadge = (policy: string) => {
|
||||
if (policy === 'public-read') {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-2.5 py-0.5 text-xs font-medium text-orange-500 border border-orange-500/20">
|
||||
Public Read
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-green-500/10 px-2.5 py-0.5 text-xs font-medium text-green-500 border border-green-500/20">
|
||||
Private
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex flex-col bg-background-dark">
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col overflow-y-auto relative scroll-smooth">
|
||||
<div className="flex flex-col max-w-[1200px] w-full mx-auto p-6 md:p-8 lg:p-12 gap-8">
|
||||
{/* Page Heading */}
|
||||
<div className="flex flex-wrap justify-between items-start gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-white tracking-tight text-[32px] font-bold leading-tight">
|
||||
Object Storage Service
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm font-normal max-w-xl">
|
||||
Manage S3-compatible buckets, configure access policies, and monitor real-time object storage performance.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="flex h-10 items-center justify-center rounded-lg border border-border-dark px-4 text-white text-sm font-medium hover:bg-[#233648] transition-colors">
|
||||
<FileText className="mr-2" size={20} />
|
||||
Documentation
|
||||
</button>
|
||||
<button className="flex h-10 items-center justify-center rounded-lg bg-[#233648] px-4 text-white text-sm font-medium hover:bg-[#2b4055] transition-colors border border-border-dark">
|
||||
<Settings className="mr-2" size={20} />
|
||||
Config
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Service Status */}
|
||||
<div className="flex flex-col gap-2 rounded-lg p-5 border border-border-dark bg-[#1c2936]">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-text-secondary text-sm font-medium">Service Status</p>
|
||||
<CheckCircle2 className="text-[#0bda5b]" size={20} />
|
||||
</div>
|
||||
<p className="text-white tracking-tight text-2xl font-bold">Running</p>
|
||||
<p className="text-text-secondary text-xs">Port 9000 (TLS Enabled)</p>
|
||||
</div>
|
||||
|
||||
{/* Total Usage */}
|
||||
<div className="flex flex-col gap-2 rounded-lg p-5 border border-border-dark bg-[#1c2936]">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-text-secondary text-sm font-medium">Total Usage</p>
|
||||
<HardDrive className="text-primary" size={20} />
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-white tracking-tight text-2xl font-bold">{formatBytes(totalUsage, 1)}</p>
|
||||
<p className="text-[#0bda5b] text-sm font-medium">+2.1%</p>
|
||||
</div>
|
||||
<div className="w-full bg-[#233648] rounded-full h-1.5 mt-1">
|
||||
<div className="bg-primary h-1.5 rounded-full" style={{ width: '45%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Object Count */}
|
||||
<div className="flex flex-col gap-2 rounded-lg p-5 border border-border-dark bg-[#1c2936]">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-text-secondary text-sm font-medium">Object Count</p>
|
||||
<Database className="text-blue-400" size={20} />
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-white tracking-tight text-2xl font-bold">
|
||||
{(totalObjects / 1000000).toFixed(1)}M
|
||||
</p>
|
||||
<p className="text-[#0bda5b] text-sm font-medium">+0.5%</p>
|
||||
</div>
|
||||
<p className="text-text-secondary text-xs">Total objects across all buckets</p>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<div className="flex flex-col gap-2 rounded-lg p-5 border border-border-dark bg-[#1c2936]">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-text-secondary text-sm font-medium">Uptime</p>
|
||||
<Clock className="text-orange-400" size={20} />
|
||||
</div>
|
||||
<p className="text-white tracking-tight text-2xl font-bold">24d 12h</p>
|
||||
<p className="text-text-secondary text-xs">Since last patch</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endpoint Info */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6 rounded-lg border border-border-dark bg-[#16202a] p-6 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 h-full w-1/3 bg-gradient-to-l from-primary/10 to-transparent pointer-events-none"></div>
|
||||
<div className="flex flex-col gap-2 z-10 max-w-2xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="text-primary" size={20} />
|
||||
<h2 className="text-white text-lg font-bold">S3 Endpoint URL</h2>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Use this URL to configure your S3 clients (MinIO, AWS CLI, Veeam, etc.).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full md:w-auto min-w-[320px] max-w-[500px] z-10">
|
||||
<div className="flex w-full items-stretch rounded-lg h-12 shadow-md">
|
||||
<input
|
||||
className="flex-1 bg-[#233648] border border-border-dark border-r-0 rounded-l-lg px-4 text-white text-sm font-mono focus:ring-1 focus:ring-primary focus:border-primary outline-none"
|
||||
readOnly
|
||||
value={S3_ENDPOINT}
|
||||
/>
|
||||
<button
|
||||
onClick={copyEndpoint}
|
||||
className="bg-primary hover:bg-blue-600 text-white px-4 rounded-r-lg text-sm font-bold transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Copy size={18} />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs & Main Action Area */}
|
||||
<div className="flex flex-col gap-0">
|
||||
{/* Tabs Header */}
|
||||
<div className="border-b border-border-dark">
|
||||
<div className="flex gap-8 px-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('buckets')}
|
||||
className={`flex items-center gap-2 border-b-2 pb-3 pt-2 transition-colors ${
|
||||
activeTab === 'buckets'
|
||||
? 'border-primary text-white'
|
||||
: 'border-transparent text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Folder size={20} />
|
||||
<span className="text-sm font-bold">Buckets</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`flex items-center gap-2 border-b-2 pb-3 pt-2 transition-colors ${
|
||||
activeTab === 'users'
|
||||
? 'border-primary text-white'
|
||||
: 'border-transparent text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Users size={20} />
|
||||
<span className="text-sm font-bold">Users & Keys</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('monitoring')}
|
||||
className={`flex items-center gap-2 border-b-2 pb-3 pt-2 transition-colors ${
|
||||
activeTab === 'monitoring'
|
||||
? 'border-primary text-white'
|
||||
: 'border-transparent text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Activity size={20} />
|
||||
<span className="text-sm font-bold">Monitoring</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`flex items-center gap-2 border-b-2 pb-3 pt-2 transition-colors ${
|
||||
activeTab === 'settings'
|
||||
? 'border-primary text-white'
|
||||
: 'border-transparent text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Filter size={20} />
|
||||
<span className="text-sm font-bold">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="py-6 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="relative w-full md:w-96">
|
||||
<Search className="absolute inset-y-0 left-0 flex items-center pl-3 text-text-secondary" size={20} />
|
||||
<input
|
||||
className="w-full bg-[#233648] border border-border-dark text-white text-sm rounded-lg pl-10 pr-4 py-2.5 focus:ring-1 focus:ring-primary focus:border-primary outline-none placeholder:text-text-secondary"
|
||||
placeholder="Filter buckets..."
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="flex items-center justify-center gap-2 bg-primary hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg text-sm font-bold transition-colors w-full md:w-auto shadow-lg shadow-blue-900/20">
|
||||
<Plus size={20} />
|
||||
Create Bucket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'buckets' && (
|
||||
<>
|
||||
{/* Data Table */}
|
||||
<div className="w-full overflow-hidden rounded-lg border border-border-dark bg-[#1c2936]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border-dark bg-[#16202a]">
|
||||
<th className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||
Usage
|
||||
</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||
Objects
|
||||
</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||
Access Policy
|
||||
</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold uppercase tracking-wider text-text-secondary text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-dark">
|
||||
{paginatedBuckets.map((bucket) => (
|
||||
<tr key={bucket.id} className="group hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg ${getBucketColorClass(
|
||||
bucket.color
|
||||
)}`}
|
||||
>
|
||||
{getBucketIcon(bucket)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-white text-sm font-bold">{bucket.name}</p>
|
||||
<p className="text-text-secondary text-xs">{bucket.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-1 w-32">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-white font-medium">{formatBytes(bucket.usage, 1)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-[#111a22] rounded-full">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${getProgressColor(bucket.color)}`}
|
||||
style={{ width: `${bucket.usagePercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<p className="text-white text-sm">{bucket.objects.toLocaleString()}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">{getAccessPolicyBadge(bucket.accessPolicy)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<p className="text-text-secondary text-sm">{formatDate(bucket.created)}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<button className="text-text-secondary hover:text-white transition-colors p-2 rounded hover:bg-white/5">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between border-t border-border-dark px-6 py-3 bg-[#16202a]">
|
||||
<p className="text-xs text-text-secondary">
|
||||
Showing <span className="font-medium text-white">{paginatedBuckets.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0}</span> to{' '}
|
||||
<span className="font-medium text-white">
|
||||
{Math.min(currentPage * itemsPerPage, filteredBuckets.length)}
|
||||
</span>{' '}
|
||||
of <span className="font-medium text-white">{filteredBuckets.length}</span> buckets
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="flex items-center justify-center h-8 w-8 rounded bg-[#233648] text-text-secondary hover:text-white hover:bg-[#2b4055] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="text-sm">‹</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="flex items-center justify-center h-8 w-8 rounded bg-[#233648] text-text-secondary hover:text-white hover:bg-[#2b4055] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="text-sm">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
|
||||
<Users className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm">Users & Keys management coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'monitoring' && (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
|
||||
<Activity className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm">Monitoring dashboard coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
|
||||
<Settings className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm">Settings configuration coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-12 w-full shrink-0"></div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
971
frontend/src/pages/Shares.tsx
Normal file
971
frontend/src/pages/Shares.tsx
Normal file
@@ -0,0 +1,971 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { sharesAPI, type Share, type CreateShareRequest, type UpdateShareRequest } from '@/api/shares'
|
||||
import { zfsApi, type ZFSDataset } from '@/api/storage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Plus, Search, FolderOpen, Share as ShareIcon, Cloud, Settings, X, ChevronRight,
|
||||
FolderSymlink, Network, Lock, History, Save, Gauge, Server,
|
||||
ChevronDown, Ban
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function SharesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedShare, setSelectedShare] = useState<Share | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'configuration' | 'permissions' | 'clients'>('configuration')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [formData, setFormData] = useState<CreateShareRequest>({
|
||||
dataset_id: '',
|
||||
nfs_enabled: false,
|
||||
nfs_options: 'rw,sync,no_subtree_check',
|
||||
nfs_clients: [],
|
||||
smb_enabled: false,
|
||||
smb_share_name: '',
|
||||
smb_comment: '',
|
||||
smb_guest_ok: false,
|
||||
smb_read_only: false,
|
||||
smb_browseable: true,
|
||||
})
|
||||
const [nfsClientInput, setNfsClientInput] = useState('')
|
||||
|
||||
const { data: shares = [], isLoading } = useQuery<Share[]>({
|
||||
queryKey: ['shares'],
|
||||
queryFn: sharesAPI.listShares,
|
||||
refetchInterval: 5000,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
// Get all datasets for create form
|
||||
const { data: pools = [] } = useQuery({
|
||||
queryKey: ['storage', 'zfs', 'pools'],
|
||||
queryFn: zfsApi.listPools,
|
||||
})
|
||||
|
||||
const [datasets, setDatasets] = useState<ZFSDataset[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDatasets = async () => {
|
||||
const allDatasets: ZFSDataset[] = []
|
||||
for (const pool of pools) {
|
||||
try {
|
||||
const poolDatasets = await zfsApi.listDatasets(pool.id)
|
||||
// Filter only filesystem datasets
|
||||
const filesystems = poolDatasets.filter(ds => ds.type === 'filesystem')
|
||||
allDatasets.push(...filesystems)
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch datasets for pool ${pool.id}:`, err)
|
||||
}
|
||||
}
|
||||
setDatasets(allDatasets)
|
||||
}
|
||||
if (pools.length > 0) {
|
||||
fetchDatasets()
|
||||
}
|
||||
}, [pools])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: sharesAPI.createShare,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shares'] })
|
||||
setShowCreateForm(false)
|
||||
setFormData({
|
||||
dataset_id: '',
|
||||
nfs_enabled: false,
|
||||
nfs_options: 'rw,sync,no_subtree_check',
|
||||
nfs_clients: [],
|
||||
smb_enabled: false,
|
||||
smb_share_name: '',
|
||||
smb_comment: '',
|
||||
smb_guest_ok: false,
|
||||
smb_read_only: false,
|
||||
smb_browseable: true,
|
||||
})
|
||||
alert('Share created successfully!')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorMessage = error?.response?.data?.error || error?.message || 'Failed to create share'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Failed to create share:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateShareRequest }) =>
|
||||
sharesAPI.updateShare(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shares'] })
|
||||
},
|
||||
})
|
||||
|
||||
const filteredShares = shares.filter(share =>
|
||||
share.dataset_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
share.mount_point.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const handleCreateShare = (e?: React.MouseEvent) => {
|
||||
e?.preventDefault()
|
||||
e?.stopPropagation()
|
||||
|
||||
console.log('Creating share with data:', formData)
|
||||
|
||||
if (!formData.dataset_id) {
|
||||
alert('Please select a dataset')
|
||||
return
|
||||
}
|
||||
if (!formData.nfs_enabled && !formData.smb_enabled) {
|
||||
alert('At least one protocol (NFS or SMB) must be enabled')
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare the data to send
|
||||
const submitData: CreateShareRequest = {
|
||||
dataset_id: formData.dataset_id,
|
||||
nfs_enabled: formData.nfs_enabled,
|
||||
smb_enabled: formData.smb_enabled,
|
||||
}
|
||||
|
||||
if (formData.nfs_enabled) {
|
||||
submitData.nfs_options = formData.nfs_options || 'rw,sync,no_subtree_check'
|
||||
submitData.nfs_clients = formData.nfs_clients || []
|
||||
}
|
||||
|
||||
if (formData.smb_enabled) {
|
||||
submitData.smb_share_name = formData.smb_share_name || ''
|
||||
submitData.smb_comment = formData.smb_comment || ''
|
||||
submitData.smb_guest_ok = formData.smb_guest_ok || false
|
||||
submitData.smb_read_only = formData.smb_read_only || false
|
||||
submitData.smb_browseable = formData.smb_browseable !== undefined ? formData.smb_browseable : true
|
||||
}
|
||||
|
||||
console.log('Submitting share data:', submitData)
|
||||
createMutation.mutate(submitData)
|
||||
}
|
||||
|
||||
const handleToggleNFS = (share: Share) => {
|
||||
updateMutation.mutate({
|
||||
id: share.id,
|
||||
data: { nfs_enabled: !share.nfs_enabled },
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleSMB = (share: Share) => {
|
||||
updateMutation.mutate({
|
||||
id: share.id,
|
||||
data: { smb_enabled: !share.smb_enabled },
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddNFSClient = (share: Share) => {
|
||||
if (!nfsClientInput.trim()) return
|
||||
const newClients = [...(share.nfs_clients || []), nfsClientInput.trim()]
|
||||
updateMutation.mutate({
|
||||
id: share.id,
|
||||
data: { nfs_clients: newClients },
|
||||
})
|
||||
setNfsClientInput('')
|
||||
}
|
||||
|
||||
const handleRemoveNFSClient = (share: Share, client: string) => {
|
||||
const newClients = (share.nfs_clients || []).filter(c => c !== client)
|
||||
updateMutation.mutate({
|
||||
id: share.id,
|
||||
data: { nfs_clients: newClients },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex flex-col bg-background-dark">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 border-b border-border-dark bg-background-dark/95 backdrop-blur z-10">
|
||||
<div className="flex flex-col gap-4 p-6 pb-4">
|
||||
<div className="flex flex-wrap justify-between gap-3 items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-white text-3xl font-black leading-tight tracking-[-0.033em]">Shares Management</h2>
|
||||
<div className="flex items-center gap-2 text-text-secondary text-sm">
|
||||
<span>Storage</span>
|
||||
<ChevronRight size={14} />
|
||||
<span>Shares</span>
|
||||
<ChevronRight size={14} />
|
||||
<span className="text-white">Overview</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="flex items-center gap-2 px-4 h-10 rounded-lg bg-primary hover:bg-blue-600 text-white text-sm font-bold"
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span>Create New Share</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
|
||||
<div className="flex flex-col gap-1 rounded-lg p-4 border border-border-dark bg-surface-dark/50">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-xs font-bold uppercase tracking-wider">SMB Service</p>
|
||||
<div className="size-2 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]"></div>
|
||||
</div>
|
||||
<p className="text-white text-xl font-bold leading-tight">Running</p>
|
||||
<p className="text-emerald-500 text-xs mt-1">Port 445 Active</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 rounded-lg p-4 border border-border-dark bg-surface-dark/50">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-xs font-bold uppercase tracking-wider">NFS Service</p>
|
||||
<div className="size-2 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]"></div>
|
||||
</div>
|
||||
<p className="text-white text-xl font-bold leading-tight">Running</p>
|
||||
<p className="text-emerald-500 text-xs mt-1">Port 2049 Active</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 rounded-lg p-4 border border-border-dark bg-surface-dark/50">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-xs font-bold uppercase tracking-wider">Throughput</p>
|
||||
<Gauge className="text-text-secondary" size={20} />
|
||||
</div>
|
||||
<p className="text-white text-xl font-bold leading-tight">565 MB/s</p>
|
||||
<p className="text-text-secondary text-xs mt-1">14 Clients Connected</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Master-Detail Layout */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Panel: Shares List */}
|
||||
<div className="w-full lg:w-[400px] flex flex-col border-r border-border-dark bg-background-dark flex-shrink-0">
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b border-border-dark bg-background-dark sticky top-0 z-10">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 text-text-secondary" size={18} />
|
||||
<input
|
||||
className="w-full bg-surface-dark border border-border-dark rounded-lg pl-10 pr-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary placeholder-text-secondary"
|
||||
placeholder="Filter shares..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Items */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-text-secondary text-sm">Loading shares...</div>
|
||||
) : filteredShares.length === 0 ? (
|
||||
<div className="p-4 text-center text-text-secondary text-sm">No shares found</div>
|
||||
) : (
|
||||
filteredShares.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
onClick={() => setSelectedShare(share)}
|
||||
className={`group flex flex-col border-b border-border-dark/50 cursor-pointer transition-colors ${
|
||||
selectedShare?.id === share.id
|
||||
? 'bg-primary/10 border-l-4 border-l-primary'
|
||||
: 'hover:bg-surface-dark'
|
||||
}`}
|
||||
>
|
||||
<div className={`px-4 py-3 flex items-start gap-3 ${selectedShare?.id === share.id ? 'pl-3' : ''}`}>
|
||||
{selectedShare?.id === share.id ? (
|
||||
<Server className="text-primary mt-1" size={20} />
|
||||
) : (
|
||||
<FolderOpen className="text-text-secondary mt-1" size={20} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h3 className={`text-sm truncate ${selectedShare?.id === share.id ? 'font-bold text-white' : 'font-medium text-white'}`}>
|
||||
{share.dataset_name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className={`text-xs truncate mb-2 ${selectedShare?.id === share.id ? 'text-primary/80' : 'text-text-secondary'}`}>
|
||||
{share.mount_point || 'No mount point'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{share.smb_enabled ? (
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${
|
||||
selectedShare?.id === share.id
|
||||
? 'bg-surface-dark text-text-secondary border-border-dark'
|
||||
: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
}`}>
|
||||
SMB
|
||||
</span>
|
||||
) : null}
|
||||
{share.nfs_enabled && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-emerald-500/10 text-emerald-500 border border-emerald-500/20">
|
||||
NFS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedShare?.id !== share.id && (
|
||||
<ChevronRight className="text-text-secondary text-[18px]" size={18} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-border-dark bg-background-dark text-center">
|
||||
<p className="text-xs text-text-secondary">
|
||||
Showing {filteredShares.length} of {shares.length} shares
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Share Details */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-background-light dark:bg-[#0d141c]">
|
||||
{selectedShare ? (
|
||||
<>
|
||||
{/* Detail Header */}
|
||||
<div className="p-6 pb-0 flex flex-col gap-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-primary p-2 rounded-lg text-white">
|
||||
<Server size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{selectedShare.dataset_name.split('/').pop() || selectedShare.dataset_name}
|
||||
</h2>
|
||||
<p className="text-text-secondary text-sm font-mono">{selectedShare.dataset_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center justify-center rounded-lg h-9 px-4 border border-border-dark text-white text-sm font-medium hover:bg-surface-dark transition-colors"
|
||||
>
|
||||
<History size={18} className="mr-2" />
|
||||
Revert
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center justify-center rounded-lg h-9 px-4 bg-primary text-white text-sm font-bold shadow-lg shadow-primary/20 hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<Save size={18} className="mr-2" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Protocol Toggles */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* SMB Toggle */}
|
||||
<div className={`flex items-center justify-between p-4 rounded-xl border ${
|
||||
selectedShare.smb_enabled
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border-dark bg-surface-dark/40'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
selectedShare.smb_enabled
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-surface-dark text-text-secondary'
|
||||
}`}>
|
||||
<ShareIcon size={20} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-white">SMB Protocol</span>
|
||||
<span className="text-xs text-text-secondary">Windows File Sharing</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleSMB(selectedShare)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background-dark ${
|
||||
selectedShare.smb_enabled ? 'bg-primary' : 'bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
selectedShare.smb_enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* NFS Toggle */}
|
||||
<div className={`flex items-center justify-between p-4 rounded-xl border ${
|
||||
selectedShare.nfs_enabled
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border-dark bg-surface-dark/40'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
selectedShare.nfs_enabled
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-surface-dark text-text-secondary'
|
||||
}`}>
|
||||
<Cloud size={20} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-white">NFS Protocol</span>
|
||||
<span className="text-xs text-text-secondary">Unix/Linux File Sharing</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleNFS(selectedShare)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background-dark ${
|
||||
selectedShare.nfs_enabled ? 'bg-primary' : 'bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
selectedShare.nfs_enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-border-dark mt-2">
|
||||
<div className="flex gap-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('configuration')}
|
||||
className={`pb-3 border-b-2 text-sm flex items-center gap-2 transition-colors ${
|
||||
activeTab === 'configuration'
|
||||
? 'border-primary text-primary font-bold'
|
||||
: 'border-transparent text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
<Settings size={18} />
|
||||
Configuration
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('permissions')}
|
||||
className={`pb-3 border-b-2 text-sm flex items-center gap-2 transition-colors ${
|
||||
activeTab === 'permissions'
|
||||
? 'border-primary text-primary font-bold'
|
||||
: 'border-transparent text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
<Lock size={18} />
|
||||
Permissions (ACL)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('clients')}
|
||||
className={`pb-3 border-b-2 text-sm flex items-center gap-2 transition-colors ${
|
||||
activeTab === 'clients'
|
||||
? 'border-primary text-primary font-bold'
|
||||
: 'border-transparent text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
<Network size={18} />
|
||||
Connected Clients
|
||||
<span className="bg-surface-dark text-white text-[10px] px-1.5 py-0.5 rounded-full ml-1">8</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-4xl flex flex-col gap-6">
|
||||
{activeTab === 'configuration' && (
|
||||
<>
|
||||
{/* NFS Settings Card */}
|
||||
{selectedShare.nfs_enabled && (
|
||||
<div className="rounded-xl border border-border-dark bg-surface-dark overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-border-dark flex justify-between items-center bg-[#1c2a39]">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Network className="text-primary" size={20} />
|
||||
NFS Configuration
|
||||
</h3>
|
||||
<span className="text-xs text-emerald-500 font-medium px-2 py-1 bg-emerald-500/10 rounded border border-emerald-500/20">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Allowed Subnets / IPs</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary font-mono"
|
||||
type="text"
|
||||
placeholder="192.168.10.0/24"
|
||||
value={nfsClientInput}
|
||||
onChange={(e) => setNfsClientInput(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddNFSClient(selectedShare)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleAddNFSClient(selectedShare)}
|
||||
className="p-2 bg-surface-dark hover:bg-border-dark border border-border-dark rounded-lg text-white"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary">CIDR notation supported. Use comma for multiple entries.</p>
|
||||
{selectedShare.nfs_clients && selectedShare.nfs_clients.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{selectedShare.nfs_clients.map((client) => (
|
||||
<span
|
||||
key={client}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/20 text-primary text-xs rounded border border-primary/30"
|
||||
>
|
||||
{client}
|
||||
<button
|
||||
onClick={() => handleRemoveNFSClient(selectedShare, client)}
|
||||
className="hover:text-red-400"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Map Root User</label>
|
||||
<div className="relative">
|
||||
<select className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary appearance-none">
|
||||
<option>root (User ID 0)</option>
|
||||
<option>admin</option>
|
||||
<option>nobody</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-2.5 text-text-secondary pointer-events-none" size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Security Profile</label>
|
||||
<div className="flex gap-2">
|
||||
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border-dark bg-background-dark cursor-pointer flex-1">
|
||||
<input
|
||||
checked
|
||||
className="text-primary focus:ring-primary bg-surface-dark border-border-dark"
|
||||
name="sec"
|
||||
type="radio"
|
||||
/>
|
||||
<span className="text-sm text-white">sys (Default)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border-dark bg-background-dark cursor-pointer flex-1">
|
||||
<input
|
||||
className="text-primary focus:ring-primary bg-surface-dark border-border-dark"
|
||||
name="sec"
|
||||
type="radio"
|
||||
/>
|
||||
<span className="text-sm text-white">krb5</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Sync Mode</label>
|
||||
<div className="relative">
|
||||
<select className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary appearance-none">
|
||||
<option>Standard</option>
|
||||
<option>Always Sync</option>
|
||||
<option>Disabled (Async)</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-2.5 text-text-secondary pointer-events-none" size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SMB Settings Card */}
|
||||
{selectedShare.smb_enabled && (
|
||||
<div className="rounded-xl border border-border-dark bg-surface-dark overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-border-dark flex justify-between items-center bg-[#1c2a39]">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<ShareIcon className="text-primary" size={20} />
|
||||
SMB Configuration
|
||||
</h3>
|
||||
<span className="text-xs text-emerald-500 font-medium px-2 py-1 bg-emerald-500/10 rounded border border-emerald-500/20">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Share Name</label>
|
||||
<input
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
type="text"
|
||||
value={selectedShare.smb_share_name || ''}
|
||||
onChange={(e) => {
|
||||
updateMutation.mutate({
|
||||
id: selectedShare.id,
|
||||
data: { smb_share_name: e.target.value },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Path</label>
|
||||
<input
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary font-mono"
|
||||
type="text"
|
||||
value={selectedShare.smb_path || selectedShare.mount_point || ''}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-text-secondary uppercase">Comment</label>
|
||||
<input
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
type="text"
|
||||
value={selectedShare.smb_comment || ''}
|
||||
onChange={(e) => {
|
||||
updateMutation.mutate({
|
||||
id: selectedShare.id,
|
||||
data: { smb_comment: e.target.value },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedShare.smb_guest_ok}
|
||||
onChange={(e) => {
|
||||
updateMutation.mutate({
|
||||
id: selectedShare.id,
|
||||
data: { smb_guest_ok: e.target.checked },
|
||||
})
|
||||
}}
|
||||
className="rounded border-border-dark bg-background-dark text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Allow Guest Access</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedShare.smb_read_only}
|
||||
onChange={(e) => {
|
||||
updateMutation.mutate({
|
||||
id: selectedShare.id,
|
||||
data: { smb_read_only: e.target.checked },
|
||||
})
|
||||
}}
|
||||
className="rounded border-border-dark bg-background-dark text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Read Only</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedShare.smb_browseable}
|
||||
onChange={(e) => {
|
||||
updateMutation.mutate({
|
||||
id: selectedShare.id,
|
||||
data: { smb_browseable: e.target.checked },
|
||||
})
|
||||
}}
|
||||
className="rounded border-border-dark bg-background-dark text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Browseable</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Attributes */}
|
||||
<div className="rounded-xl border border-border-dark bg-surface-dark overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="w-full px-5 py-4 flex justify-between items-center hover:bg-[#1c2a39] transition-colors text-left"
|
||||
>
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Settings className="text-text-secondary" size={20} />
|
||||
Advanced Attributes
|
||||
</h3>
|
||||
<ChevronDown
|
||||
className={`text-text-secondary transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
|
||||
size={20}
|
||||
/>
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="p-5 border-t border-border-dark flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-border-dark bg-background-dark text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Read Only</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
checked
|
||||
type="checkbox"
|
||||
className="rounded border-border-dark bg-background-dark text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Enable Compression (LZ4)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-border-dark bg-background-dark text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Enable Deduplication</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connected Clients Preview */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex justify-between items-end">
|
||||
<h3 className="text-base font-bold text-white">Top Active Clients</h3>
|
||||
<a className="text-sm text-primary hover:text-blue-400 font-medium cursor-pointer" href="#">
|
||||
View all clients
|
||||
</a>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border-dark overflow-hidden bg-surface-dark">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-background-dark text-text-secondary font-medium border-b border-border-dark">
|
||||
<tr>
|
||||
<th className="px-4 py-3">IP Address</th>
|
||||
<th className="px-4 py-3">User</th>
|
||||
<th className="px-4 py-3">Protocol</th>
|
||||
<th className="px-4 py-3 text-right">Throughput</th>
|
||||
<th className="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-white divide-y divide-border-dark">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">192.168.10.105</td>
|
||||
<td className="px-4 py-3">esxi-host-01</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs font-bold">NFS</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-text-secondary">420 MB/s</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="text-text-secondary hover:text-red-400" title="Disconnect">
|
||||
<Ban size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">192.168.10.106</td>
|
||||
<td className="px-4 py-3">esxi-host-02</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs font-bold">NFS</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-text-secondary">105 MB/s</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="text-text-secondary hover:text-red-400" title="Disconnect">
|
||||
<Ban size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'permissions' && (
|
||||
<div className="rounded-xl border border-border-dark bg-surface-dark p-8 text-center">
|
||||
<Lock className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm">Permissions (ACL) configuration coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'clients' && (
|
||||
<div className="rounded-xl border border-border-dark bg-surface-dark overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-border-dark flex justify-between items-center bg-[#1c2a39]">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Network className="text-primary" size={20} />
|
||||
Connected Clients
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="rounded-lg border border-border-dark overflow-hidden bg-surface-dark">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-background-dark text-text-secondary font-medium border-b border-border-dark">
|
||||
<tr>
|
||||
<th className="px-4 py-3">IP Address</th>
|
||||
<th className="px-4 py-3">User</th>
|
||||
<th className="px-4 py-3">Protocol</th>
|
||||
<th className="px-4 py-3 text-right">Throughput</th>
|
||||
<th className="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-white divide-y divide-border-dark">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">192.168.10.105</td>
|
||||
<td className="px-4 py-3">esxi-host-01</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs font-bold">NFS</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-text-secondary">420 MB/s</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="text-text-secondary hover:text-red-400" title="Disconnect">
|
||||
<Ban size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">192.168.10.106</td>
|
||||
<td className="px-4 py-3">esxi-host-02</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs font-bold">NFS</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-text-secondary">105 MB/s</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="text-text-secondary hover:text-red-400" title="Disconnect">
|
||||
<Ban size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-text-secondary">
|
||||
<div className="text-center">
|
||||
<FolderOpen className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-sm">Select a share to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Share Modal */}
|
||||
{showCreateForm && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-surface-dark rounded-xl border border-border-dark max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b border-border-dark flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold text-white">Create New Share</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
className="text-text-secondary hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Dataset</label>
|
||||
<select
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
value={formData.dataset_id}
|
||||
onChange={(e) => setFormData({ ...formData, dataset_id: e.target.value })}
|
||||
>
|
||||
<option value="">Select a dataset</option>
|
||||
{datasets.map((ds) => (
|
||||
<option key={ds.id} value={ds.id}>
|
||||
{ds.name} {ds.mount_point && ds.mount_point !== 'none' ? `(${ds.mount_point})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl border border-border-dark bg-surface-dark/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<Network className="text-text-secondary" size={20} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-white">Enable NFS</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.nfs_enabled}
|
||||
onChange={(e) => setFormData({ ...formData, nfs_enabled: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border-dark bg-background-dark text-primary focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl border border-border-dark bg-surface-dark/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderSymlink className="text-text-secondary" size={20} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-white">Enable SMB</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.smb_enabled}
|
||||
onChange={(e) => setFormData({ ...formData, smb_enabled: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border-dark bg-background-dark text-primary focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.nfs_enabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">NFS Options</label>
|
||||
<input
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary font-mono"
|
||||
type="text"
|
||||
value={formData.nfs_options}
|
||||
onChange={(e) => setFormData({ ...formData, nfs_options: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.smb_enabled && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">SMB Share Name</label>
|
||||
<input
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
type="text"
|
||||
value={formData.smb_share_name}
|
||||
onChange={(e) => setFormData({ ...formData, smb_share_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Comment</label>
|
||||
<input
|
||||
className="w-full bg-background-dark border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
type="text"
|
||||
value={formData.smb_comment}
|
||||
onChange={(e) => setFormData({ ...formData, smb_comment: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
variant="outline"
|
||||
className="px-4 h-10"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreateShare}
|
||||
disabled={createMutation.isPending}
|
||||
className="px-4 h-10 bg-primary hover:bg-blue-600"
|
||||
>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Share'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
872
frontend/src/pages/SnapshotReplication.tsx
Normal file
872
frontend/src/pages/SnapshotReplication.tsx
Normal file
@@ -0,0 +1,872 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import { zfsApi } from '@/api/storage'
|
||||
import {
|
||||
Camera,
|
||||
History,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Calendar,
|
||||
RotateCcw,
|
||||
Copy,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
CloudSync,
|
||||
AlertCircle,
|
||||
MoreVertical,
|
||||
X,
|
||||
Save
|
||||
} from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// Mock data - will be replaced with API calls
|
||||
const MOCK_SNAPSHOTS = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'auto-2023-10-27-0000',
|
||||
dataset: 'tank/home',
|
||||
created: '2023-10-27T00:00:00Z',
|
||||
referenced: 1.2 * 1024 * 1024, // 1.2 MB
|
||||
isLatest: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'manual-backup-pre-upgrade',
|
||||
dataset: 'tank/services/db',
|
||||
created: '2023-10-26T16:30:00Z',
|
||||
referenced: 4.5 * 1024 * 1024 * 1024, // 4.5 GB
|
||||
isLatest: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'auto-2023-10-26-0000',
|
||||
dataset: 'tank/home',
|
||||
created: '2023-10-26T00:00:00Z',
|
||||
referenced: 850 * 1024, // 850 KB
|
||||
isLatest: false,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'auto-2023-10-25-0000',
|
||||
dataset: 'tank/home',
|
||||
created: '2023-10-25T00:00:00Z',
|
||||
referenced: 1.1 * 1024 * 1024, // 1.1 MB
|
||||
isLatest: false,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'auto-2023-10-24-0000',
|
||||
dataset: 'tank/home',
|
||||
created: '2023-10-24T00:00:00Z',
|
||||
referenced: 920 * 1024, // 920 KB
|
||||
isLatest: false,
|
||||
},
|
||||
]
|
||||
|
||||
const MOCK_REPLICATIONS = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Daily Offsite (tank/backup)',
|
||||
target: '192.168.20.5 (ssh)',
|
||||
status: 'idle',
|
||||
lastRun: '15m ago',
|
||||
progress: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Hourly Sync (tank/projects)',
|
||||
target: '192.168.20.5 (ssh)',
|
||||
status: 'running',
|
||||
lastRun: 'Running...',
|
||||
progress: 45,
|
||||
},
|
||||
]
|
||||
|
||||
export default function SnapshotReplication() {
|
||||
const [activeTab, setActiveTab] = useState<'snapshots' | 'replication' | 'restore'>('snapshots')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [showCreateReplication, setShowCreateReplication] = useState(false)
|
||||
const [showCreateSnapshot, setShowCreateSnapshot] = useState(false)
|
||||
const itemsPerPage = 10
|
||||
|
||||
// Form state for replication
|
||||
const [replicationForm, setReplicationForm] = useState({
|
||||
name: '',
|
||||
sourceDataset: '',
|
||||
targetHost: '',
|
||||
targetDataset: '',
|
||||
targetPort: '22',
|
||||
targetUser: 'root',
|
||||
schedule: 'daily',
|
||||
scheduleTime: '00:00',
|
||||
compression: 'lz4',
|
||||
encryption: false,
|
||||
recursive: true,
|
||||
autoSnapshot: true,
|
||||
})
|
||||
|
||||
// Fetch pools and datasets for form
|
||||
const { data: pools = [] } = useQuery({
|
||||
queryKey: ['replication-pools'],
|
||||
queryFn: zfsApi.listPools,
|
||||
})
|
||||
|
||||
const [datasets, setDatasets] = useState<Array<{ pool: string; datasets: any[] }>>([])
|
||||
|
||||
// Fetch datasets for selected pool
|
||||
const fetchDatasets = async (poolName: string) => {
|
||||
try {
|
||||
const poolDatasets = await zfsApi.listDatasets(poolName)
|
||||
setDatasets((prev) => {
|
||||
const filtered = prev.filter((d) => d.pool !== poolName)
|
||||
return [...filtered, { pool: poolName, datasets: poolDatasets }]
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch datasets:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter snapshots
|
||||
const filteredSnapshots = MOCK_SNAPSHOTS.filter((snapshot) => {
|
||||
const matchesSearch =
|
||||
snapshot.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
snapshot.dataset.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesSearch
|
||||
})
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(filteredSnapshots.length / itemsPerPage)
|
||||
const paginatedSnapshots = filteredSnapshots.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
)
|
||||
|
||||
// Calculate totals
|
||||
const totalSnapshots = MOCK_SNAPSHOTS.length
|
||||
const totalReclaimable = MOCK_SNAPSHOTS.reduce((sum, s) => sum + s.referenced, 0)
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex flex-col bg-background-dark">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-8 py-5 border-b border-[#233648] bg-background-dark shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/storage" className="text-text-secondary hover:text-white text-sm font-medium transition-colors">
|
||||
Storage
|
||||
</Link>
|
||||
<ChevronRight className="text-[#536b85]" size={16} />
|
||||
<Link to="/storage" className="text-text-secondary hover:text-white text-sm font-medium transition-colors">
|
||||
Pools
|
||||
</Link>
|
||||
<ChevronRight className="text-[#536b85]" size={16} />
|
||||
<span className="text-white text-sm font-bold bg-[#1e2936] px-2 py-1 rounded">Data Protection</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="flex items-center justify-center w-8 h-8 rounded-full hover:bg-[#1e2936] text-text-secondary transition-colors relative">
|
||||
<AlertCircle size={20} />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full border border-background-dark"></span>
|
||||
</button>
|
||||
<button className="flex items-center justify-center w-8 h-8 rounded-full hover:bg-[#1e2936] text-text-secondary transition-colors">
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Scrollable Page Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
|
||||
<div className="mx-auto max-w-[1200px] flex flex-col gap-8">
|
||||
{/* Page Heading */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-white text-3xl font-extrabold tracking-tight">Snapshots & Replication</h1>
|
||||
<p className="text-text-secondary text-base font-normal max-w-2xl">
|
||||
Manage local ZFS snapshots and configure remote replication tasks to ensure data redundancy and disaster recovery.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="px-4 py-2 bg-[#1e2936] hover:bg-[#2a3b4d] text-white text-sm font-bold rounded-lg border border-[#324d67] transition-all flex items-center gap-2">
|
||||
<History size={18} />
|
||||
View Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTab === 'replication') {
|
||||
setShowCreateReplication(true)
|
||||
} else {
|
||||
setShowCreateSnapshot(true)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-bold rounded-lg shadow-[0_4px_12px_rgba(19,127,236,0.3)] transition-all flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{activeTab === 'replication' ? 'Create Replication' : 'Create Snapshot'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Total Snapshots */}
|
||||
<div className="flex flex-col gap-1 rounded-xl p-5 bg-[#18232e] border border-[#2a3b4d] relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Camera className="text-6xl text-primary" />
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Total Snapshots</p>
|
||||
<div className="flex items-end gap-2">
|
||||
<p className="text-white text-3xl font-bold tracking-tight">{totalSnapshots.toLocaleString()}</p>
|
||||
<span className="text-emerald-400 text-sm font-medium mb-1 flex items-center">
|
||||
<TrendingUp size={14} className="mr-0.5" />
|
||||
+12 today
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[#536b85] text-xs font-medium mt-1">{formatBytes(totalReclaimable, 1)} Reclaimable Space</p>
|
||||
</div>
|
||||
|
||||
{/* Last Replication */}
|
||||
<div className="flex flex-col gap-1 rounded-xl p-5 bg-[#18232e] border border-[#2a3b4d] relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<RefreshCw className="text-6xl text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Last Replication</p>
|
||||
<div className="flex items-end gap-2">
|
||||
<p className="text-white text-3xl font-bold tracking-tight">Success</p>
|
||||
</div>
|
||||
<p className="text-[#536b85] text-xs font-medium mt-1">15 mins ago • tank/backup</p>
|
||||
</div>
|
||||
|
||||
{/* Next Scheduled */}
|
||||
<div className="flex flex-col gap-1 rounded-xl p-5 bg-[#18232e] border border-[#2a3b4d] relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Clock className="text-6xl text-purple-500" />
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Next Scheduled</p>
|
||||
<div className="flex items-end gap-2">
|
||||
<p className="text-white text-3xl font-bold tracking-tight">10:00 PM</p>
|
||||
</div>
|
||||
<p className="text-[#536b85] text-xs font-medium mt-1">Daily Offsite Backup</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Section */}
|
||||
<div className="flex flex-col bg-[#18232e] border border-[#2a3b4d] rounded-xl overflow-hidden shadow-sm">
|
||||
{/* Tabs Header */}
|
||||
<div className="flex border-b border-[#2a3b4d] bg-[#151f29]">
|
||||
<button
|
||||
onClick={() => setActiveTab('snapshots')}
|
||||
className={`px-6 py-4 text-sm font-bold flex items-center gap-2 transition-colors ${
|
||||
activeTab === 'snapshots'
|
||||
? 'text-white border-b-2 border-primary bg-[#18232e]'
|
||||
: 'text-text-secondary hover:text-white border-b-2 border-transparent hover:bg-[#18232e]'
|
||||
}`}
|
||||
>
|
||||
<Camera size={20} />
|
||||
Snapshots
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('replication')}
|
||||
className={`px-6 py-4 text-sm font-bold flex items-center gap-2 transition-colors ${
|
||||
activeTab === 'replication'
|
||||
? 'text-white border-b-2 border-primary bg-[#18232e]'
|
||||
: 'text-text-secondary hover:text-white border-b-2 border-transparent hover:bg-[#18232e]'
|
||||
}`}
|
||||
>
|
||||
<CloudSync size={20} />
|
||||
Replication Tasks
|
||||
<span className="ml-1 bg-[#2a3b4d] text-white text-[10px] px-1.5 py-0.5 rounded-full">
|
||||
{MOCK_REPLICATIONS.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('restore')}
|
||||
className={`px-6 py-4 text-sm font-bold flex items-center gap-2 transition-colors ${
|
||||
activeTab === 'restore'
|
||||
? 'text-white border-b-2 border-primary bg-[#18232e]'
|
||||
: 'text-text-secondary hover:text-white border-b-2 border-transparent hover:bg-[#18232e]'
|
||||
}`}
|
||||
>
|
||||
<RotateCcw size={20} />
|
||||
Restore Points
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="p-4 flex flex-col sm:flex-row gap-4 justify-between items-center border-b border-[#2a3b4d] bg-[#18232e]">
|
||||
<div className="relative w-full sm:w-96 group">
|
||||
<Search className="absolute left-3 top-2.5 text-[#536b85] group-focus-within:text-primary transition-colors" size={20} />
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] text-white text-sm rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all placeholder-[#536b85]"
|
||||
placeholder="Search snapshot name or dataset..."
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 w-full sm:w-auto">
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[#111a22] border border-[#2a3b4d] rounded-lg text-sm font-medium text-text-secondary hover:text-white hover:border-[#536b85] transition-all">
|
||||
<Filter size={18} />
|
||||
Dataset: All
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[#111a22] border border-[#2a3b4d] rounded-lg text-sm font-medium text-text-secondary hover:text-white hover:border-[#536b85] transition-all">
|
||||
<Calendar size={18} />
|
||||
Date Range
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'snapshots' && (
|
||||
<>
|
||||
{/* Data Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[#151f29] text-text-secondary">
|
||||
<tr>
|
||||
<th className="p-4 border-b border-[#2a3b4d] w-[50px]">
|
||||
<input
|
||||
className="w-4 h-4 rounded border-[#324d67] bg-[#111a22] text-primary focus:ring-offset-[#111a22]"
|
||||
type="checkbox"
|
||||
/>
|
||||
</th>
|
||||
<th className="p-4 border-b border-[#2a3b4d] text-xs font-bold uppercase tracking-wider">
|
||||
Snapshot Name
|
||||
</th>
|
||||
<th className="p-4 border-b border-[#2a3b4d] text-xs font-bold uppercase tracking-wider">Dataset</th>
|
||||
<th className="p-4 border-b border-[#2a3b4d] text-xs font-bold uppercase tracking-wider">Created</th>
|
||||
<th className="p-4 border-b border-[#2a3b4d] text-xs font-bold uppercase tracking-wider">Referenced</th>
|
||||
<th className="p-4 border-b border-[#2a3b4d] text-xs font-bold uppercase tracking-wider text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm">
|
||||
{paginatedSnapshots.map((snapshot) => (
|
||||
<tr key={snapshot.id} className="group hover:bg-[#1e2936] transition-colors border-b border-[#2a3b4d]">
|
||||
<td className="p-4">
|
||||
<input
|
||||
className="w-4 h-4 rounded border-[#324d67] bg-[#111a22] text-primary focus:ring-offset-[#111a22]"
|
||||
type="checkbox"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Camera className="text-[#536b85]" size={20} />
|
||||
<span className="text-white font-medium">{snapshot.name}</span>
|
||||
{snapshot.isLatest && (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-primary/20 text-primary border border-primary/20">
|
||||
LATEST
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-text-secondary">{snapshot.dataset}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-text-secondary">{formatDate(snapshot.created)}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-white font-mono">{formatBytes(snapshot.referenced)}</span>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
className="p-1.5 hover:bg-[#2a3b4d] rounded text-text-secondary hover:text-white"
|
||||
title="Rollback"
|
||||
>
|
||||
<RotateCcw size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 hover:bg-[#2a3b4d] rounded text-text-secondary hover:text-white"
|
||||
title="Clone"
|
||||
>
|
||||
<Copy size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 hover:bg-red-500/20 rounded text-text-secondary hover:text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between p-4 bg-[#18232e]">
|
||||
<p className="text-text-secondary text-sm">
|
||||
Showing <span className="text-white font-bold">1-{paginatedSnapshots.length}</span> of{' '}
|
||||
<span className="text-white font-bold">{filteredSnapshots.length}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm rounded border border-[#2a3b4d] text-text-secondary hover:bg-[#2a3b4d] hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: Math.min(3, totalPages) }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`px-3 py-1 text-sm rounded border border-[#2a3b4d] ${
|
||||
currentPage === page
|
||||
? 'text-white bg-[#2a3b4d]'
|
||||
: 'text-text-secondary hover:bg-[#2a3b4d] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
{totalPages > 3 && <span className="text-text-secondary">...</span>}
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm rounded border border-[#2a3b4d] text-text-secondary hover:bg-[#2a3b4d] hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'replication' && (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-white text-lg font-bold">Replication Tasks</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateReplication(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-bold rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Create Replication
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{MOCK_REPLICATIONS.map((replication) => (
|
||||
<div
|
||||
key={replication.id}
|
||||
className="flex items-center gap-4 bg-[#111a22] p-4 rounded-lg border border-[#2a3b4d] hover:bg-[#1e2936] transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
replication.status === 'running'
|
||||
? 'bg-primary animate-pulse'
|
||||
: 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]'
|
||||
}`}
|
||||
></div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white text-sm font-medium">{replication.name}</p>
|
||||
<p className="text-[#536b85] text-xs">Target: {replication.target}</p>
|
||||
{replication.status === 'running' && (
|
||||
<div className="w-full bg-[#2a3b4d] rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
className="bg-primary h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${replication.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`text-sm ${
|
||||
replication.status === 'running' ? 'text-primary font-bold' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{replication.status === 'running' ? `${replication.progress}%` : 'Idle'}
|
||||
</p>
|
||||
<p className="text-[#536b85] text-xs">{replication.lastRun}</p>
|
||||
</div>
|
||||
<button className="p-2 hover:bg-[#2a3b4d] rounded text-text-secondary hover:text-white transition-colors">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'restore' && (
|
||||
<div className="p-8 text-center">
|
||||
<RotateCcw className="mx-auto mb-4 text-text-secondary" size={48} />
|
||||
<p className="text-text-secondary text-sm">Restore points coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Info / Replication Quick Status */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Replication Status */}
|
||||
<div className="bg-[#18232e] border border-[#2a3b4d] rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-white font-bold text-lg">Replication Status</h3>
|
||||
<a className="text-primary text-sm font-bold hover:underline" href="#">
|
||||
Manage All
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{MOCK_REPLICATIONS.map((replication) => (
|
||||
<div
|
||||
key={replication.id}
|
||||
className="flex items-center gap-4 bg-[#111a22] p-3 rounded-lg border border-[#2a3b4d]"
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
replication.status === 'running'
|
||||
? 'bg-primary animate-pulse'
|
||||
: 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]'
|
||||
}`}
|
||||
></div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white text-sm font-medium">{replication.name}</p>
|
||||
<p className="text-[#536b85] text-xs">Target: {replication.target}</p>
|
||||
{replication.status === 'running' && (
|
||||
<div className="w-full bg-[#2a3b4d] rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
className="bg-primary h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${replication.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`text-sm ${
|
||||
replication.status === 'running' ? 'text-primary font-bold' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{replication.status === 'running' ? `${replication.progress}%` : 'Idle'}
|
||||
</p>
|
||||
<p className="text-[#536b85] text-xs">{replication.lastRun}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snapshot Retention */}
|
||||
<div className="bg-[#18232e] border border-[#2a3b4d] rounded-xl p-6 flex flex-col justify-center items-center text-center">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-3">
|
||||
<AlertCircle className="text-primary text-2xl" />
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-lg">Snapshot Retention</h3>
|
||||
<p className="text-text-secondary text-sm mt-1 mb-4">
|
||||
You have 14 snapshots marked for expiration in the next 24 hours.
|
||||
</p>
|
||||
<button className="text-white bg-[#2a3b4d] hover:bg-[#324d67] px-4 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||
Review Expiration Policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Replication Modal */}
|
||||
{showCreateReplication && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowCreateReplication(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-[#18232e] rounded-xl border border-[#2a3b4d] max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b border-[#2a3b4d] flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold text-white">Create Replication Task</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateReplication(false)}
|
||||
className="text-text-secondary hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
{/* Task Name */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Task Name</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none"
|
||||
type="text"
|
||||
placeholder="Daily Offsite Backup"
|
||||
value={replicationForm.name}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source Dataset */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Source Dataset</label>
|
||||
<select
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none appearance-none"
|
||||
value={replicationForm.sourceDataset}
|
||||
onChange={(e) => {
|
||||
setReplicationForm({ ...replicationForm, sourceDataset: e.target.value })
|
||||
const poolName = e.target.value.split('/')[0]
|
||||
if (poolName) {
|
||||
fetchDatasets(poolName)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Select a dataset</option>
|
||||
{pools.map((pool) => (
|
||||
<optgroup key={pool.id} label={pool.name}>
|
||||
{datasets
|
||||
.find((d) => d.pool === pool.name)
|
||||
?.datasets.filter((ds) => ds.type === 'filesystem')
|
||||
.map((ds) => (
|
||||
<option key={ds.id} value={ds.name}>
|
||||
{ds.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Target Configuration */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Target Host</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none font-mono"
|
||||
type="text"
|
||||
placeholder="192.168.20.5"
|
||||
value={replicationForm.targetHost}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, targetHost: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">SSH Port</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none font-mono"
|
||||
type="number"
|
||||
placeholder="22"
|
||||
value={replicationForm.targetPort}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, targetPort: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Target User</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none"
|
||||
type="text"
|
||||
placeholder="root"
|
||||
value={replicationForm.targetUser}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, targetUser: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Target Dataset</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none font-mono"
|
||||
type="text"
|
||||
placeholder="tank/backup"
|
||||
value={replicationForm.targetDataset}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, targetDataset: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Schedule</label>
|
||||
<select
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none appearance-none"
|
||||
value={replicationForm.schedule}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, schedule: e.target.value })}
|
||||
>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="custom">Custom (Cron)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Time</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none font-mono"
|
||||
type="time"
|
||||
value={replicationForm.scheduleTime}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, scheduleTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Compression</label>
|
||||
<select
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none appearance-none"
|
||||
value={replicationForm.compression}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, compression: e.target.value })}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="lz4">LZ4 (Fast)</option>
|
||||
<option value="gzip">GZIP</option>
|
||||
<option value="zstd">ZSTD</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Checkboxes */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={replicationForm.recursive}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, recursive: e.target.checked })}
|
||||
className="rounded border-[#324d67] bg-[#111a22] text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Recursive (include child datasets)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={replicationForm.autoSnapshot}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, autoSnapshot: e.target.checked })}
|
||||
className="rounded border-[#324d67] bg-[#111a22] text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Auto-create snapshot before replication</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={replicationForm.encryption}
|
||||
onChange={(e) => setReplicationForm({ ...replicationForm, encryption: e.target.checked })}
|
||||
className="rounded border-[#324d67] bg-[#111a22] text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-white">Enable encryption (SSH tunnel)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[#2a3b4d]">
|
||||
<Button
|
||||
onClick={() => setShowCreateReplication(false)}
|
||||
variant="outline"
|
||||
className="px-4 h-10 border-[#2a3b4d] text-white hover:bg-[#2a3b4d]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// TODO: Implement API call
|
||||
console.log('Creating replication:', replicationForm)
|
||||
alert('Replication task created successfully!')
|
||||
setShowCreateReplication(false)
|
||||
setReplicationForm({
|
||||
name: '',
|
||||
sourceDataset: '',
|
||||
targetHost: '',
|
||||
targetDataset: '',
|
||||
targetPort: '22',
|
||||
targetUser: 'root',
|
||||
schedule: 'daily',
|
||||
scheduleTime: '00:00',
|
||||
compression: 'lz4',
|
||||
encryption: false,
|
||||
recursive: true,
|
||||
autoSnapshot: true,
|
||||
})
|
||||
}}
|
||||
className="px-4 h-10 bg-primary hover:bg-blue-600"
|
||||
>
|
||||
<Save size={18} className="mr-2" />
|
||||
Create Replication
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Snapshot Modal */}
|
||||
{showCreateSnapshot && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowCreateSnapshot(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-[#18232e] rounded-xl border border-[#2a3b4d] max-w-lg w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b border-[#2a3b4d] flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold text-white">Create Snapshot</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateSnapshot(false)}
|
||||
className="text-text-secondary hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Snapshot Name</label>
|
||||
<input
|
||||
className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none"
|
||||
type="text"
|
||||
placeholder="manual-backup-2024"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">Dataset</label>
|
||||
<select className="w-full bg-[#111a22] border border-[#2a3b4d] rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none">
|
||||
<option value="">Select a dataset</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
onClick={() => setShowCreateSnapshot(false)}
|
||||
variant="outline"
|
||||
className="px-4 h-10 border-[#2a3b4d] text-white hover:bg-[#2a3b4d]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
alert('Snapshot created successfully!')
|
||||
setShowCreateSnapshot(false)
|
||||
}}
|
||||
className="px-4 h-10 bg-primary hover:bg-blue-600"
|
||||
>
|
||||
Create Snapshot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export default function System() {
|
||||
const [ntpServers, setNtpServers] = useState<string[]>(['pool.ntp.org', 'time.google.com'])
|
||||
const [showAddNtpServer, setShowAddNtpServer] = useState(false)
|
||||
const [newNtpServer, setNewNtpServer] = useState('')
|
||||
const [showLicenseModal, setShowLicenseModal] = useState(false)
|
||||
const [licenseKey, setLicenseKey] = useState('')
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
@@ -120,6 +122,93 @@ export default function System() {
|
||||
|
||||
{/* Grid Layout */}
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
{/* Feature License Card */}
|
||||
<div className="flex flex-col rounded-xl border border-border-dark bg-card-dark shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-dark px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-primary">verified</span>
|
||||
<h2 className="text-lg font-bold text-white">Feature License</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-xs text-text-secondary">Licensed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
{/* License Status */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-bold text-white">License Status</p>
|
||||
<p className="text-xs text-text-secondary">Enterprise Edition</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-green-500/10 border border-green-500/20">
|
||||
<span className="material-symbols-outlined text-green-500 text-[16px]">check_circle</span>
|
||||
<span className="text-xs font-bold text-green-500">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* License Details */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-secondary">License Key</span>
|
||||
<span className="text-xs font-mono text-white">CAL-****-****-****-****-****</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-secondary">Expires</span>
|
||||
<span className="text-xs font-bold text-white">Dec 31, 2025</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-secondary">Days Remaining</span>
|
||||
<span className="text-xs font-bold text-emerald-400">365 days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enabled Features */}
|
||||
<div className="border-t border-border-dark pt-4">
|
||||
<h3 className="text-sm font-bold text-white mb-3">Enabled Features</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{ name: 'Advanced Replication', enabled: true },
|
||||
{ name: 'Encryption at Rest', enabled: true },
|
||||
{ name: 'Deduplication', enabled: true },
|
||||
{ name: 'Cloud Backup Integration', enabled: true },
|
||||
{ name: 'Multi-Site Sync', enabled: true },
|
||||
{ name: 'Advanced Monitoring', enabled: true },
|
||||
].map((feature) => (
|
||||
<div key={feature.name} className="flex items-center justify-between p-2 rounded bg-[#111a22]">
|
||||
<span className="text-xs text-white">{feature.name}</span>
|
||||
{feature.enabled ? (
|
||||
<span className="material-symbols-outlined text-green-500 text-[16px]">check_circle</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-text-secondary text-[16px]">cancel</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="border-t border-border-dark pt-4 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setShowLicenseModal(true)}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-white hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">key</span>
|
||||
Update License Key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement download license info
|
||||
alert('Downloading license information...')
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg bg-border-dark px-4 py-2.5 text-sm font-bold text-white hover:bg-[#2f455a] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">download</span>
|
||||
Download License Info
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Network Card */}
|
||||
<div className="flex flex-col rounded-xl border border-border-dark bg-card-dark shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-dark px-6 py-4">
|
||||
@@ -536,6 +625,83 @@ export default function System() {
|
||||
onClose={() => setViewingInterface(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* License Key Modal */}
|
||||
{showLicenseModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowLicenseModal(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-card-dark rounded-xl border border-border-dark max-w-lg w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b border-border-dark flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold text-white">Update License Key</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLicenseModal(false)
|
||||
setLicenseKey('')
|
||||
}}
|
||||
className="text-text-secondary hover:text-white"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-white">License Key</label>
|
||||
<textarea
|
||||
className="w-full bg-[#111a22] border border-border-dark rounded-lg px-3 py-2 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary outline-none font-mono resize-none"
|
||||
rows={4}
|
||||
placeholder="Paste your license key here..."
|
||||
value={licenseKey}
|
||||
onChange={(e) => setLicenseKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Enter your license key to activate or update features. The key will be validated automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-yellow-500 text-[18px]">info</span>
|
||||
<span className="text-xs font-bold text-yellow-500">Important</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Updating the license key will restart the system services. Make sure you have a valid license key before proceeding.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border-dark">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLicenseModal(false)
|
||||
setLicenseKey('')
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg border border-border-dark text-white hover:bg-border-dark transition-colors text-sm font-bold"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!licenseKey.trim()) {
|
||||
alert('Please enter a license key')
|
||||
return
|
||||
}
|
||||
// TODO: Implement API call to update license
|
||||
console.log('Updating license key:', licenseKey)
|
||||
alert('License key updated successfully!')
|
||||
setShowLicenseModal(false)
|
||||
setLicenseKey('')
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-primary hover:bg-blue-600 text-white transition-colors text-sm font-bold"
|
||||
>
|
||||
Update License
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
323
frontend/src/pages/TerminalConsole.tsx
Normal file
323
frontend/src/pages/TerminalConsole.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Terminal, RefreshCw, Trash2, Command } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import apiClient from '@/api/client'
|
||||
|
||||
interface CommandHistory {
|
||||
command: string
|
||||
output: string
|
||||
timestamp: Date
|
||||
error?: boolean
|
||||
service?: string
|
||||
}
|
||||
|
||||
export default function TerminalConsole() {
|
||||
const [commandHistory, setCommandHistory] = useState<CommandHistory[]>([])
|
||||
const [currentCommand, setCurrentCommand] = useState('')
|
||||
const [isExecuting, setIsExecuting] = useState(false)
|
||||
const [selectedService, setSelectedService] = useState<string>('system')
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Auto-scroll to bottom when new output is added
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.scrollTop = terminalRef.current.scrollHeight
|
||||
}
|
||||
}, [commandHistory])
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const executeCommand = useMutation({
|
||||
mutationFn: async (cmd: string) => {
|
||||
const response = await apiClient.post('/system/execute', {
|
||||
command: cmd,
|
||||
service: selectedService,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data, command) => {
|
||||
setCommandHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
command,
|
||||
output: data.output || data.error || 'Command executed',
|
||||
timestamp: new Date(),
|
||||
error: !!data.error,
|
||||
service: selectedService,
|
||||
},
|
||||
])
|
||||
setCurrentCommand('')
|
||||
setIsExecuting(false)
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, 100)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setCommandHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
command: currentCommand,
|
||||
output: error?.response?.data?.error || error?.response?.data?.output || error.message || 'Error executing command',
|
||||
timestamp: new Date(),
|
||||
error: true,
|
||||
service: selectedService,
|
||||
},
|
||||
])
|
||||
setCurrentCommand('')
|
||||
setIsExecuting(false)
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, 100)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const cmd = currentCommand.trim()
|
||||
if (!cmd || isExecuting) return
|
||||
|
||||
setIsExecuting(true)
|
||||
executeCommand.mutate(cmd)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Handle Ctrl+L to clear
|
||||
if (e.ctrlKey && e.key === 'l') {
|
||||
e.preventDefault()
|
||||
if (confirm('Clear terminal history?')) {
|
||||
setCommandHistory([])
|
||||
}
|
||||
}
|
||||
// Handle Up arrow for command history (future enhancement)
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
// TODO: Implement command history navigation
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (confirm('Clear terminal history?')) {
|
||||
setCommandHistory([])
|
||||
}
|
||||
}
|
||||
|
||||
const getServiceCommands = (service: string) => {
|
||||
const commands: Record<string, string[]> = {
|
||||
system: [
|
||||
'ls -la',
|
||||
'df -h',
|
||||
'free -h',
|
||||
'systemctl status scst',
|
||||
'scstadmin -list_target',
|
||||
'scstadmin -list_device',
|
||||
'ip addr show',
|
||||
'journalctl -u calypso-api -n 50',
|
||||
'ps aux | grep calypso',
|
||||
'netstat -tulpn | grep 8080',
|
||||
],
|
||||
scst: [
|
||||
'scstadmin -list_target',
|
||||
'scstadmin -list_device',
|
||||
'scstadmin -list_handler',
|
||||
'scstadmin -list_driver',
|
||||
'scstadmin -list_group',
|
||||
'scstadmin -list',
|
||||
'cat /etc/scst.conf',
|
||||
'systemctl status scst',
|
||||
'systemctl status iscsi-scst',
|
||||
],
|
||||
storage: [
|
||||
'zfs list',
|
||||
'zpool status',
|
||||
'zpool list',
|
||||
'lsblk',
|
||||
'df -h',
|
||||
'zfs get all',
|
||||
'zpool get all',
|
||||
],
|
||||
backup: [
|
||||
'bconsole -c "list jobs"',
|
||||
'bconsole -c "list clients"',
|
||||
'bconsole -c "list pools"',
|
||||
'systemctl status bacula-director',
|
||||
'systemctl status bacula-sd',
|
||||
'systemctl status bacula-fd',
|
||||
],
|
||||
tape: [
|
||||
'lsscsi -g',
|
||||
'mtx -f /dev/sgX status',
|
||||
'sg_inq /dev/sgX',
|
||||
'systemctl status mhvtl',
|
||||
],
|
||||
}
|
||||
return commands[service] || []
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-[1400px] mx-auto flex flex-col gap-6 h-full">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap justify-between items-end gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-white text-3xl font-extrabold leading-tight tracking-tight flex items-center gap-3">
|
||||
<Terminal className="text-primary" size={32} />
|
||||
Terminal Console
|
||||
</h1>
|
||||
<p className="text-text-secondary text-base font-normal">
|
||||
Execute shell commands and manage all appliance services
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span>Clear</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Selector */}
|
||||
<div className="flex items-center gap-4 p-4 bg-card-dark border border-border-dark rounded-lg">
|
||||
<span className="text-text-secondary text-sm font-medium">Service:</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['system', 'scst', 'storage', 'backup', 'tape'].map((service) => (
|
||||
<button
|
||||
key={service}
|
||||
onClick={() => setSelectedService(service)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
selectedService === service
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-[#0f161d] text-text-secondary hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{service.charAt(0).toUpperCase() + service.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal */}
|
||||
<div className="flex-1 flex flex-col bg-[#0a0f14] border border-border-dark rounded-lg overflow-hidden min-h-[600px]">
|
||||
{/* Terminal Output */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 overflow-y-auto p-4 custom-scrollbar"
|
||||
style={{ minHeight: '400px' }}
|
||||
>
|
||||
{commandHistory.length === 0 ? (
|
||||
<div className="text-text-secondary">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Terminal className="text-primary" size={20} />
|
||||
<span className="text-white font-semibold">Terminal Console</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-primary/20 text-primary rounded">
|
||||
{selectedService}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2">Type commands below to execute shell commands</div>
|
||||
<div className="text-xs opacity-70 mt-4">
|
||||
<div className="font-semibold mb-2">Common commands for {selectedService}:</div>
|
||||
<div className="ml-4 space-y-1">
|
||||
{getServiceCommands(selectedService).map((cmd, idx) => (
|
||||
<div key={idx}>
|
||||
• <span className="text-primary font-mono">{cmd}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
commandHistory.map((item, idx) => (
|
||||
<div key={idx} className="mb-6">
|
||||
<div className="text-primary mb-2 font-mono text-sm flex items-center gap-2">
|
||||
<span className="text-text-secondary">$</span>
|
||||
<span className="text-white">{item.command}</span>
|
||||
{item.service && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/20 text-primary rounded">
|
||||
{item.service}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-secondary text-xs ml-auto">
|
||||
{new Date(item.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`font-mono text-xs leading-relaxed whitespace-pre overflow-x-auto ${
|
||||
item.error ? 'text-red-400' : 'text-green-400'
|
||||
}`}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
lineHeight: '1.6',
|
||||
tabSize: 2,
|
||||
}}
|
||||
>
|
||||
{item.output}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isExecuting && (
|
||||
<div className="text-text-secondary flex items-center gap-2">
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
<span>Executing command...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terminal Input */}
|
||||
<div className="flex-none border-t border-border-dark bg-[#161f29]">
|
||||
<form onSubmit={handleSubmit} className="flex items-center">
|
||||
<div className="px-4 flex items-center gap-2">
|
||||
<span className="text-primary font-mono text-sm">$</span>
|
||||
<span className="text-xs text-text-secondary px-1.5 py-0.5 bg-primary/20 text-primary rounded">
|
||||
{selectedService}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={currentCommand}
|
||||
onChange={(e) => setCurrentCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isExecuting}
|
||||
placeholder={`Enter ${selectedService} command...`}
|
||||
className="flex-1 bg-transparent text-white font-mono text-sm py-3 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!currentCommand.trim() || isExecuting}
|
||||
className="px-4 py-3 text-primary hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Command size={16} className={isExecuting ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex-none px-6 py-3 border-t border-border-dark bg-[#141d26] flex items-center justify-between text-xs text-text-secondary">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Terminal Console - Command Execution</span>
|
||||
<span>• {commandHistory.length} commands executed</span>
|
||||
</div>
|
||||
<div>
|
||||
Press <kbd className="px-1.5 py-0.5 bg-[#0a0f14] border border-border-dark rounded text-xs">Ctrl+L</kbd> to clear
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user