add feature license management

This commit is contained in:
Warp Agent
2026-01-04 12:54:25 +07:00
parent 7543b3a850
commit 2bb64620d4
29 changed files with 5447 additions and 22 deletions

Binary file not shown.

View File

@@ -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

View File

@@ -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

View File

@@ -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=

View File

@@ -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 {

View File

@@ -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

View File

@@ -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,
})
}

View File

@@ -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"`

View 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"})
}

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

View File

@@ -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,

View File

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

View File

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

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

View 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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 />} />

View File

@@ -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 {

View 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}`)
},
}

View File

@@ -166,6 +166,7 @@ export const zfsApi = {
}
export interface ZFSDataset {
id: string
name: string
pool: string
type: string // filesystem, volume, snapshot

View File

@@ -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 },
]

View File

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

View 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: &gt;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>
)
}

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

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

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

View File

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

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