Compare commits
7 Commits
main
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7543b3a850 | ||
|
|
a558c97088 | ||
|
|
2de3c5f6ab | ||
|
|
8ece52992b | ||
|
|
03965e35fb | ||
|
|
ebaf718424 | ||
|
|
cb923704db |
Binary file not shown.
@@ -116,3 +116,268 @@ func (h *Handler) CreateJob(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusCreated, job)
|
||||
}
|
||||
|
||||
// ExecuteBconsoleCommand executes a bconsole command
|
||||
func (h *Handler) ExecuteBconsoleCommand(c *gin.Context) {
|
||||
var req struct {
|
||||
Command string `json:"command" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "command is required"})
|
||||
return
|
||||
}
|
||||
|
||||
output, err := h.service.ExecuteBconsoleCommand(c.Request.Context(), req.Command)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to execute bconsole command", "error", err, "command", req.Command)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "failed to execute command",
|
||||
"output": output,
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"output": output,
|
||||
})
|
||||
}
|
||||
|
||||
// ListClients lists all backup clients with optional filters
|
||||
func (h *Handler) ListClients(c *gin.Context) {
|
||||
opts := ListClientsOptions{}
|
||||
|
||||
// Parse enabled filter
|
||||
if enabledStr := c.Query("enabled"); enabledStr != "" {
|
||||
enabled := enabledStr == "true"
|
||||
opts.Enabled = &enabled
|
||||
}
|
||||
|
||||
// Parse search query
|
||||
opts.Search = c.Query("search")
|
||||
|
||||
clients, err := h.service.ListClients(c.Request.Context(), opts)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list clients", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "failed to list clients",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if clients == nil {
|
||||
clients = []Client{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"clients": clients,
|
||||
"total": len(clients),
|
||||
})
|
||||
}
|
||||
|
||||
// GetDashboardStats returns dashboard statistics
|
||||
func (h *Handler) GetDashboardStats(c *gin.Context) {
|
||||
stats, err := h.service.GetDashboardStats(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get dashboard stats", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get dashboard stats"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// ListStoragePools lists all storage pools
|
||||
func (h *Handler) ListStoragePools(c *gin.Context) {
|
||||
pools, err := h.service.ListStoragePools(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list storage pools", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list storage pools"})
|
||||
return
|
||||
}
|
||||
|
||||
if pools == nil {
|
||||
pools = []StoragePool{}
|
||||
}
|
||||
|
||||
h.logger.Info("Listed storage pools", "count", len(pools))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pools": pools,
|
||||
"total": len(pools),
|
||||
})
|
||||
}
|
||||
|
||||
// ListStorageVolumes lists all storage volumes
|
||||
func (h *Handler) ListStorageVolumes(c *gin.Context) {
|
||||
poolName := c.Query("pool_name")
|
||||
|
||||
volumes, err := h.service.ListStorageVolumes(c.Request.Context(), poolName)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list storage volumes", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list storage volumes"})
|
||||
return
|
||||
}
|
||||
|
||||
if volumes == nil {
|
||||
volumes = []StorageVolume{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"volumes": volumes,
|
||||
"total": len(volumes),
|
||||
})
|
||||
}
|
||||
|
||||
// ListStorageDaemons lists all storage daemons
|
||||
func (h *Handler) ListStorageDaemons(c *gin.Context) {
|
||||
daemons, err := h.service.ListStorageDaemons(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list storage daemons", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list storage daemons"})
|
||||
return
|
||||
}
|
||||
|
||||
if daemons == nil {
|
||||
daemons = []StorageDaemon{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"daemons": daemons,
|
||||
"total": len(daemons),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateStoragePool creates a new storage pool
|
||||
func (h *Handler) CreateStoragePool(c *gin.Context) {
|
||||
var req CreatePoolRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pool, err := h.service.CreateStoragePool(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create storage pool", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, pool)
|
||||
}
|
||||
|
||||
// DeleteStoragePool deletes a storage pool
|
||||
func (h *Handler) DeleteStoragePool(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
if idStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pool ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var poolID int
|
||||
if _, err := fmt.Sscanf(idStr, "%d", &poolID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pool ID"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.DeleteStoragePool(c.Request.Context(), poolID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to delete storage pool", "error", err, "pool_id", poolID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "pool deleted successfully"})
|
||||
}
|
||||
|
||||
// CreateStorageVolume creates a new storage volume
|
||||
func (h *Handler) CreateStorageVolume(c *gin.Context) {
|
||||
var req CreateVolumeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
volume, err := h.service.CreateStorageVolume(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create storage volume", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, volume)
|
||||
}
|
||||
|
||||
// UpdateStorageVolume updates a storage volume
|
||||
func (h *Handler) UpdateStorageVolume(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
if idStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "volume ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var volumeID int
|
||||
if _, err := fmt.Sscanf(idStr, "%d", &volumeID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid volume ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateVolumeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
volume, err := h.service.UpdateStorageVolume(c.Request.Context(), volumeID, req)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to update storage volume", "error", err, "volume_id", volumeID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, volume)
|
||||
}
|
||||
|
||||
// DeleteStorageVolume deletes a storage volume
|
||||
func (h *Handler) DeleteStorageVolume(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
if idStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "volume ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var volumeID int
|
||||
if _, err := fmt.Sscanf(idStr, "%d", &volumeID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid volume ID"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.DeleteStorageVolume(c.Request.Context(), volumeID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to delete storage volume", "error", err, "volume_id", volumeID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "volume deleted successfully"})
|
||||
}
|
||||
|
||||
// ListMedia lists all media from bconsole "list media" command
|
||||
func (h *Handler) ListMedia(c *gin.Context) {
|
||||
media, err := h.service.ListMedia(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list media", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if media == nil {
|
||||
media = []Media{}
|
||||
}
|
||||
|
||||
h.logger.Info("Listed media", "count", len(media))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"media": media,
|
||||
"total": len(media),
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -206,10 +206,12 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
scstGroup.GET("/targets", scstHandler.ListTargets)
|
||||
scstGroup.GET("/targets/:id", scstHandler.GetTarget)
|
||||
scstGroup.POST("/targets", scstHandler.CreateTarget)
|
||||
scstGroup.POST("/targets/:id/luns", scstHandler.AddLUN)
|
||||
scstGroup.POST("/targets/:id/luns", requirePermission("iscsi", "write"), scstHandler.AddLUN)
|
||||
scstGroup.DELETE("/targets/:id/luns/:lunId", requirePermission("iscsi", "write"), scstHandler.RemoveLUN)
|
||||
scstGroup.POST("/targets/:id/initiators", scstHandler.AddInitiator)
|
||||
scstGroup.POST("/targets/:id/enable", scstHandler.EnableTarget)
|
||||
scstGroup.POST("/targets/:id/disable", scstHandler.DisableTarget)
|
||||
scstGroup.DELETE("/targets/:id", requirePermission("iscsi", "write"), scstHandler.DeleteTarget)
|
||||
scstGroup.GET("/initiators", scstHandler.ListAllInitiators)
|
||||
scstGroup.GET("/initiators/:id", scstHandler.GetInitiator)
|
||||
scstGroup.DELETE("/initiators/:id", scstHandler.RemoveInitiator)
|
||||
@@ -223,6 +225,13 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
scstGroup.POST("/portals", scstHandler.CreatePortal)
|
||||
scstGroup.PUT("/portals/:id", scstHandler.UpdatePortal)
|
||||
scstGroup.DELETE("/portals/:id", scstHandler.DeletePortal)
|
||||
// Initiator Groups routes
|
||||
scstGroup.GET("/initiator-groups", scstHandler.ListAllInitiatorGroups)
|
||||
scstGroup.GET("/initiator-groups/:id", scstHandler.GetInitiatorGroup)
|
||||
scstGroup.POST("/initiator-groups", requirePermission("iscsi", "write"), scstHandler.CreateInitiatorGroup)
|
||||
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)
|
||||
}
|
||||
|
||||
// Physical Tape Libraries
|
||||
@@ -260,7 +269,18 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
}
|
||||
|
||||
// System Management
|
||||
systemService := system.NewService(log)
|
||||
systemHandler := system.NewHandler(log, tasks.NewEngine(db, log))
|
||||
// Set service in handler (if handler needs direct access)
|
||||
// Note: Handler already has service via NewHandler, but we need to ensure it's the same instance
|
||||
|
||||
// Start network monitoring with RRD
|
||||
if err := systemService.StartNetworkMonitoring(context.Background()); err != nil {
|
||||
log.Warn("Failed to start network monitoring", "error", err)
|
||||
} else {
|
||||
log.Info("Network monitoring started with RRD")
|
||||
}
|
||||
|
||||
systemGroup := protected.Group("/system")
|
||||
systemGroup.Use(requirePermission("system", "read"))
|
||||
{
|
||||
@@ -268,8 +288,13 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
systemGroup.GET("/services/:name", systemHandler.GetServiceStatus)
|
||||
systemGroup.POST("/services/:name/restart", systemHandler.RestartService)
|
||||
systemGroup.GET("/services/:name/logs", systemHandler.GetServiceLogs)
|
||||
systemGroup.GET("/logs", systemHandler.GetSystemLogs)
|
||||
systemGroup.GET("/network/throughput", systemHandler.GetNetworkThroughput)
|
||||
systemGroup.POST("/support-bundle", systemHandler.GenerateSupportBundle)
|
||||
systemGroup.GET("/interfaces", systemHandler.ListNetworkInterfaces)
|
||||
systemGroup.PUT("/interfaces/:name", systemHandler.UpdateNetworkInterface)
|
||||
systemGroup.GET("/ntp", systemHandler.GetNTPSettings)
|
||||
systemGroup.POST("/ntp", systemHandler.SaveNTPSettings)
|
||||
}
|
||||
|
||||
// IAM routes - GetUser can be accessed by user viewing own profile or admin
|
||||
@@ -330,9 +355,21 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
backupGroup := protected.Group("/backup")
|
||||
backupGroup.Use(requirePermission("backup", "read"))
|
||||
{
|
||||
backupGroup.GET("/dashboard/stats", backupHandler.GetDashboardStats)
|
||||
backupGroup.GET("/jobs", backupHandler.ListJobs)
|
||||
backupGroup.GET("/jobs/:id", backupHandler.GetJob)
|
||||
backupGroup.POST("/jobs", requirePermission("backup", "write"), backupHandler.CreateJob)
|
||||
backupGroup.GET("/clients", backupHandler.ListClients)
|
||||
backupGroup.GET("/storage/pools", backupHandler.ListStoragePools)
|
||||
backupGroup.POST("/storage/pools", requirePermission("backup", "write"), backupHandler.CreateStoragePool)
|
||||
backupGroup.DELETE("/storage/pools/:id", requirePermission("backup", "write"), backupHandler.DeleteStoragePool)
|
||||
backupGroup.GET("/storage/volumes", backupHandler.ListStorageVolumes)
|
||||
backupGroup.POST("/storage/volumes", requirePermission("backup", "write"), backupHandler.CreateStorageVolume)
|
||||
backupGroup.PUT("/storage/volumes/:id", requirePermission("backup", "write"), backupHandler.UpdateStorageVolume)
|
||||
backupGroup.DELETE("/storage/volumes/:id", requirePermission("backup", "write"), backupHandler.DeleteStorageVolume)
|
||||
backupGroup.GET("/media", backupHandler.ListMedia)
|
||||
backupGroup.GET("/storage/daemons", backupHandler.ListStorageDaemons)
|
||||
backupGroup.POST("/console/execute", requirePermission("backup", "write"), backupHandler.ExecuteBconsoleCommand)
|
||||
}
|
||||
|
||||
// Monitoring
|
||||
|
||||
@@ -88,11 +88,14 @@ func GetUserGroups(db *database.DB, userID string) ([]string, error) {
|
||||
for rows.Next() {
|
||||
var groupName string
|
||||
if err := rows.Scan(&groupName); err != nil {
|
||||
return nil, err
|
||||
return []string{}, err
|
||||
}
|
||||
groups = append(groups, groupName)
|
||||
}
|
||||
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,17 @@ func (h *Handler) ListUsers(c *gin.Context) {
|
||||
permissions, _ := GetUserPermissions(h.db, u.ID)
|
||||
groups, _ := GetUserGroups(h.db, u.ID)
|
||||
|
||||
// Ensure arrays are never nil (use empty slice instead)
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
if permissions == nil {
|
||||
permissions = []string{}
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
|
||||
users = append(users, map[string]interface{}{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
@@ -138,6 +149,17 @@ func (h *Handler) GetUser(c *gin.Context) {
|
||||
permissions, _ := GetUserPermissions(h.db, userID)
|
||||
groups, _ := GetUserGroups(h.db, userID)
|
||||
|
||||
// Ensure arrays are never nil (use empty slice instead)
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
if permissions == nil {
|
||||
permissions = []string{}
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
@@ -236,6 +258,8 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Allow update if roles or groups are provided, even if no other fields are updated
|
||||
// Note: req.Roles and req.Groups can be empty arrays ([]), which is different from nil
|
||||
// Empty array means "remove all roles/groups", nil means "don't change roles/groups"
|
||||
if len(updates) == 1 && req.Roles == nil && req.Groups == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
@@ -259,13 +283,14 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
|
||||
// Update roles if provided
|
||||
if req.Roles != nil {
|
||||
h.logger.Info("Updating user roles", "user_id", userID, "roles", *req.Roles)
|
||||
h.logger.Info("Updating user roles", "user_id", userID, "requested_roles", *req.Roles)
|
||||
currentRoles, err := GetUserRoles(h.db, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get current roles for user", "user_id", userID, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process user roles"})
|
||||
return
|
||||
}
|
||||
h.logger.Info("Current user roles", "user_id", userID, "current_roles", currentRoles)
|
||||
|
||||
rolesToAdd := []string{}
|
||||
rolesToRemove := []string{}
|
||||
@@ -298,8 +323,15 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("Roles to add", "user_id", userID, "roles_to_add", rolesToAdd, "count", len(rolesToAdd))
|
||||
h.logger.Info("Roles to remove", "user_id", userID, "roles_to_remove", rolesToRemove, "count", len(rolesToRemove))
|
||||
|
||||
// Add new roles
|
||||
if len(rolesToAdd) == 0 {
|
||||
h.logger.Info("No roles to add", "user_id", userID)
|
||||
}
|
||||
for _, roleName := range rolesToAdd {
|
||||
h.logger.Info("Processing role to add", "user_id", userID, "role_name", roleName)
|
||||
roleID, err := GetRoleIDByName(h.db, roleName)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -311,12 +343,13 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process roles"})
|
||||
return
|
||||
}
|
||||
h.logger.Info("Attempting to add role", "user_id", userID, "role_id", roleID, "role_name", roleName, "assigned_by", currentUser.ID)
|
||||
if err := AddUserRole(h.db, userID, roleID, currentUser.ID); err != nil {
|
||||
h.logger.Error("Failed to add role to user", "user_id", userID, "role_id", roleID, "error", err)
|
||||
// Don't return early, continue with other roles
|
||||
continue
|
||||
h.logger.Error("Failed to add role to user", "user_id", userID, "role_id", roleID, "role_name", roleName, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add role '%s': %v", roleName, err)})
|
||||
return
|
||||
}
|
||||
h.logger.Info("Role added to user", "user_id", userID, "role_name", roleName)
|
||||
h.logger.Info("Role successfully added to user", "user_id", userID, "role_id", roleID, "role_name", roleName)
|
||||
}
|
||||
|
||||
// Remove old roles
|
||||
@@ -415,8 +448,48 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("User updated", "user_id", userID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user updated successfully"})
|
||||
// Fetch updated user data to return
|
||||
updatedUser, err := GetUserByID(h.db, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to fetch updated user", "user_id", userID, "error", err)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user updated successfully"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get updated roles, permissions, and groups
|
||||
updatedRoles, _ := GetUserRoles(h.db, userID)
|
||||
updatedPermissions, _ := GetUserPermissions(h.db, userID)
|
||||
updatedGroups, _ := GetUserGroups(h.db, userID)
|
||||
|
||||
// Ensure arrays are never nil
|
||||
if updatedRoles == nil {
|
||||
updatedRoles = []string{}
|
||||
}
|
||||
if updatedPermissions == nil {
|
||||
updatedPermissions = []string{}
|
||||
}
|
||||
if updatedGroups == nil {
|
||||
updatedGroups = []string{}
|
||||
}
|
||||
|
||||
h.logger.Info("User updated", "user_id", userID, "roles", updatedRoles, "groups", updatedGroups)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "user updated successfully",
|
||||
"user": gin.H{
|
||||
"id": updatedUser.ID,
|
||||
"username": updatedUser.Username,
|
||||
"email": updatedUser.Email,
|
||||
"full_name": updatedUser.FullName,
|
||||
"is_active": updatedUser.IsActive,
|
||||
"is_system": updatedUser.IsSystem,
|
||||
"roles": updatedRoles,
|
||||
"permissions": updatedPermissions,
|
||||
"groups": updatedGroups,
|
||||
"created_at": updatedUser.CreatedAt,
|
||||
"updated_at": updatedUser.UpdatedAt,
|
||||
"last_login_at": updatedUser.LastLoginAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user
|
||||
|
||||
@@ -2,6 +2,7 @@ package iam
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
@@ -90,11 +91,14 @@ func GetUserRoles(db *database.DB, userID string) ([]string, error) {
|
||||
for rows.Next() {
|
||||
var role string
|
||||
if err := rows.Scan(&role); err != nil {
|
||||
return nil, err
|
||||
return []string{}, err
|
||||
}
|
||||
roles = append(roles, role)
|
||||
}
|
||||
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
return roles, rows.Err()
|
||||
}
|
||||
|
||||
@@ -118,11 +122,14 @@ func GetUserPermissions(db *database.DB, userID string) ([]string, error) {
|
||||
for rows.Next() {
|
||||
var perm string
|
||||
if err := rows.Scan(&perm); err != nil {
|
||||
return nil, err
|
||||
return []string{}, err
|
||||
}
|
||||
permissions = append(permissions, perm)
|
||||
}
|
||||
|
||||
if permissions == nil {
|
||||
permissions = []string{}
|
||||
}
|
||||
return permissions, rows.Err()
|
||||
}
|
||||
|
||||
@@ -133,8 +140,23 @@ func AddUserRole(db *database.DB, userID, roleID, assignedBy string) error {
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, role_id) DO NOTHING
|
||||
`
|
||||
_, err := db.Exec(query, userID, roleID, assignedBy)
|
||||
return err
|
||||
result, err := db.Exec(query, userID, roleID, assignedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert user role: %w", err)
|
||||
}
|
||||
|
||||
// Check if row was actually inserted (not just skipped due to conflict)
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
// Row already exists, this is not an error but we should know about it
|
||||
return nil // ON CONFLICT DO NOTHING means this is expected
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUserRole removes a role from a user
|
||||
|
||||
@@ -3,11 +3,13 @@ package scst
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
"github.com/atlasos/calypso/internal/tasks"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// Handler handles SCST-related API requests
|
||||
@@ -37,6 +39,11 @@ func (h *Handler) ListTargets(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure we return an empty array instead of null
|
||||
if targets == nil {
|
||||
targets = []Target{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"targets": targets})
|
||||
}
|
||||
|
||||
@@ -112,6 +119,11 @@ func (h *Handler) CreateTarget(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set alias to name for frontend compatibility (same as ListTargets)
|
||||
target.Alias = target.Name
|
||||
// LUNCount will be 0 for newly created target
|
||||
target.LUNCount = 0
|
||||
|
||||
c.JSON(http.StatusCreated, target)
|
||||
}
|
||||
|
||||
@@ -119,7 +131,7 @@ func (h *Handler) CreateTarget(c *gin.Context) {
|
||||
type AddLUNRequest struct {
|
||||
DeviceName string `json:"device_name" binding:"required"`
|
||||
DevicePath string `json:"device_path" binding:"required"`
|
||||
LUNNumber int `json:"lun_number" binding:"required"`
|
||||
LUNNumber int `json:"lun_number"` // Note: cannot use binding:"required" for int as 0 is valid
|
||||
HandlerType string `json:"handler_type" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -136,17 +148,45 @@ func (h *Handler) AddLUN(c *gin.Context) {
|
||||
var req AddLUNRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind AddLUN request", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid request: %v", err)})
|
||||
// Provide more detailed error message
|
||||
if validationErr, ok := err.(validator.ValidationErrors); ok {
|
||||
var errorMessages []string
|
||||
for _, fieldErr := range validationErr {
|
||||
errorMessages = append(errorMessages, fmt.Sprintf("%s is required", fieldErr.Field()))
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("validation failed: %s", strings.Join(errorMessages, ", "))})
|
||||
} else {
|
||||
// Extract error message without full struct name
|
||||
errMsg := err.Error()
|
||||
if idx := strings.Index(errMsg, "Key: '"); idx >= 0 {
|
||||
// Extract field name from error message
|
||||
fieldStart := idx + 6 // Length of "Key: '"
|
||||
if fieldEnd := strings.Index(errMsg[fieldStart:], "'"); fieldEnd >= 0 {
|
||||
fieldName := errMsg[fieldStart : fieldStart+fieldEnd]
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid or missing field: %s", fieldName)})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request format"})
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid request: %v", err)})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
// Validate required fields (additional check in case binding doesn't catch it)
|
||||
if req.DeviceName == "" || req.DevicePath == "" || req.HandlerType == "" {
|
||||
h.logger.Error("Missing required fields in AddLUN request", "device_name", req.DeviceName, "device_path", req.DevicePath, "handler_type", req.HandlerType)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "device_name, device_path, and handler_type are required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate LUN number range
|
||||
if req.LUNNumber < 0 || req.LUNNumber > 255 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "lun_number must be between 0 and 255"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.AddLUN(c.Request.Context(), target.IQN, req.DeviceName, req.DevicePath, req.LUNNumber, req.HandlerType); err != nil {
|
||||
h.logger.Error("Failed to add LUN", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -156,6 +196,48 @@ func (h *Handler) AddLUN(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "LUN added successfully"})
|
||||
}
|
||||
|
||||
// RemoveLUN removes a LUN from a target
|
||||
func (h *Handler) RemoveLUN(c *gin.Context) {
|
||||
targetID := c.Param("id")
|
||||
lunID := c.Param("lunId")
|
||||
|
||||
// Get target
|
||||
target, err := h.service.GetTarget(c.Request.Context(), targetID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "target not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get LUN to get the LUN number
|
||||
var lunNumber int
|
||||
err = h.db.QueryRowContext(c.Request.Context(),
|
||||
"SELECT lun_number FROM scst_luns WHERE id = $1 AND target_id = $2",
|
||||
lunID, targetID,
|
||||
).Scan(&lunNumber)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
// LUN already deleted from database - check if it still exists in SCST
|
||||
// Try to get LUN number from URL or try common LUN numbers
|
||||
// For now, return success since it's already deleted (idempotent)
|
||||
h.logger.Info("LUN not found in database, may already be deleted", "lun_id", lunID, "target_id", targetID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "LUN already removed or not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to get LUN", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get LUN"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove LUN
|
||||
if err := h.service.RemoveLUN(c.Request.Context(), target.IQN, lunNumber); err != nil {
|
||||
h.logger.Error("Failed to remove LUN", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "LUN removed successfully"})
|
||||
}
|
||||
|
||||
// AddInitiatorRequest represents an initiator addition request
|
||||
type AddInitiatorRequest struct {
|
||||
InitiatorIQN string `json:"initiator_iqn" binding:"required"`
|
||||
@@ -186,6 +268,45 @@ func (h *Handler) AddInitiator(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Initiator added successfully"})
|
||||
}
|
||||
|
||||
// AddInitiatorToGroupRequest represents a request to add an initiator to a group
|
||||
type AddInitiatorToGroupRequest struct {
|
||||
InitiatorIQN string `json:"initiator_iqn" binding:"required"`
|
||||
}
|
||||
|
||||
// AddInitiatorToGroup adds an initiator to a specific group
|
||||
func (h *Handler) AddInitiatorToGroup(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
|
||||
var req AddInitiatorToGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
validationErrors := make(map[string]string)
|
||||
if ve, ok := err.(validator.ValidationErrors); ok {
|
||||
for _, fe := range ve {
|
||||
field := strings.ToLower(fe.Field())
|
||||
validationErrors[field] = fmt.Sprintf("Field '%s' is required", field)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid request",
|
||||
"validation_errors": validationErrors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.AddInitiatorToGroup(c.Request.Context(), groupID, req.InitiatorIQN)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "single initiator only") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to add initiator to group", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add initiator to group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Initiator added to group successfully"})
|
||||
}
|
||||
|
||||
// ListAllInitiators lists all initiators across all targets
|
||||
func (h *Handler) ListAllInitiators(c *gin.Context) {
|
||||
initiators, err := h.service.ListAllInitiators(c.Request.Context())
|
||||
@@ -440,6 +561,23 @@ func (h *Handler) DisableTarget(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Target disabled successfully"})
|
||||
}
|
||||
|
||||
// DeleteTarget deletes a target
|
||||
func (h *Handler) DeleteTarget(c *gin.Context) {
|
||||
targetID := c.Param("id")
|
||||
|
||||
if err := h.service.DeleteTarget(c.Request.Context(), targetID); err != nil {
|
||||
if err.Error() == "target not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "target not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to delete target", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Target deleted successfully"})
|
||||
}
|
||||
|
||||
// DeletePortal deletes a portal
|
||||
func (h *Handler) DeletePortal(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
@@ -474,3 +612,136 @@ func (h *Handler) GetPortal(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, portal)
|
||||
}
|
||||
|
||||
// CreateInitiatorGroupRequest represents a request to create an initiator group
|
||||
type CreateInitiatorGroupRequest struct {
|
||||
TargetID string `json:"target_id" binding:"required"`
|
||||
GroupName string `json:"group_name" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateInitiatorGroup creates a new initiator group
|
||||
func (h *Handler) CreateInitiatorGroup(c *gin.Context) {
|
||||
var req CreateInitiatorGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
validationErrors := make(map[string]string)
|
||||
if ve, ok := err.(validator.ValidationErrors); ok {
|
||||
for _, fe := range ve {
|
||||
field := strings.ToLower(fe.Field())
|
||||
validationErrors[field] = fmt.Sprintf("Field '%s' is required", field)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid request",
|
||||
"validation_errors": validationErrors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
group, err := h.service.CreateInitiatorGroup(c.Request.Context(), req.TargetID, req.GroupName)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "not found") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to create initiator group", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create initiator group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, group)
|
||||
}
|
||||
|
||||
// UpdateInitiatorGroupRequest represents a request to update an initiator group
|
||||
type UpdateInitiatorGroupRequest struct {
|
||||
GroupName string `json:"group_name" binding:"required"`
|
||||
}
|
||||
|
||||
// UpdateInitiatorGroup updates an initiator group
|
||||
func (h *Handler) UpdateInitiatorGroup(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
|
||||
var req UpdateInitiatorGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
validationErrors := make(map[string]string)
|
||||
if ve, ok := err.(validator.ValidationErrors); ok {
|
||||
for _, fe := range ve {
|
||||
field := strings.ToLower(fe.Field())
|
||||
validationErrors[field] = fmt.Sprintf("Field '%s' is required", field)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid request",
|
||||
"validation_errors": validationErrors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
group, err := h.service.UpdateInitiatorGroup(c.Request.Context(), groupID, req.GroupName)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "already exists") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to update initiator group", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update initiator group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, group)
|
||||
}
|
||||
|
||||
// DeleteInitiatorGroup deletes an initiator group
|
||||
func (h *Handler) DeleteInitiatorGroup(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
|
||||
err := h.service.DeleteInitiatorGroup(c.Request.Context(), groupID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "cannot delete") || strings.Contains(err.Error(), "contains") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to delete initiator group", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete initiator group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "initiator group deleted successfully"})
|
||||
}
|
||||
|
||||
// GetInitiatorGroup retrieves an initiator group by ID
|
||||
func (h *Handler) GetInitiatorGroup(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
|
||||
group, err := h.service.GetInitiatorGroup(c.Request.Context(), groupID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "initiator group not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to get initiator group", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get initiator group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, group)
|
||||
}
|
||||
|
||||
// ListAllInitiatorGroups lists all initiator groups
|
||||
func (h *Handler) ListAllInitiatorGroups(c *gin.Context) {
|
||||
groups, err := h.service.ListAllInitiatorGroups(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list initiator groups", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list initiator groups"})
|
||||
return
|
||||
}
|
||||
|
||||
if groups == nil {
|
||||
groups = []InitiatorGroup{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package system
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
"github.com/atlasos/calypso/internal/tasks"
|
||||
@@ -131,3 +132,124 @@ func (h *Handler) ListNetworkInterfaces(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"interfaces": interfaces})
|
||||
}
|
||||
|
||||
// SaveNTPSettings saves NTP configuration to the OS
|
||||
func (h *Handler) SaveNTPSettings(c *gin.Context) {
|
||||
var settings NTPSettings
|
||||
if err := c.ShouldBindJSON(&settings); err != nil {
|
||||
h.logger.Error("Invalid request body", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate timezone
|
||||
if settings.Timezone == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "timezone is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate NTP servers
|
||||
if len(settings.NTPServers) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "at least one NTP server is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.SaveNTPSettings(c.Request.Context(), settings); err != nil {
|
||||
h.logger.Error("Failed to save NTP settings", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "NTP settings saved successfully"})
|
||||
}
|
||||
|
||||
// GetNTPSettings retrieves current NTP configuration
|
||||
func (h *Handler) GetNTPSettings(c *gin.Context) {
|
||||
settings, err := h.service.GetNTPSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get NTP settings", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get NTP settings"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
||||
}
|
||||
|
||||
// UpdateNetworkInterface updates a network interface configuration
|
||||
func (h *Handler) UpdateNetworkInterface(c *gin.Context) {
|
||||
ifaceName := c.Param("name")
|
||||
if ifaceName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "interface name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IPAddress string `json:"ip_address" binding:"required"`
|
||||
Subnet string `json:"subnet" binding:"required"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
DNS1 string `json:"dns1,omitempty"`
|
||||
DNS2 string `json:"dns2,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid request body", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to service request
|
||||
serviceReq := UpdateNetworkInterfaceRequest{
|
||||
IPAddress: req.IPAddress,
|
||||
Subnet: req.Subnet,
|
||||
Gateway: req.Gateway,
|
||||
DNS1: req.DNS1,
|
||||
DNS2: req.DNS2,
|
||||
Role: req.Role,
|
||||
}
|
||||
|
||||
updatedIface, err := h.service.UpdateNetworkInterface(c.Request.Context(), ifaceName, serviceReq)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to update network interface", "interface", ifaceName, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"interface": updatedIface})
|
||||
}
|
||||
|
||||
// GetSystemLogs retrieves recent system logs
|
||||
func (h *Handler) GetSystemLogs(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "30")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
|
||||
logs, err := h.service.GetSystemLogs(c.Request.Context(), limit)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get system logs", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get system logs"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logs})
|
||||
}
|
||||
|
||||
// GetNetworkThroughput retrieves network throughput data from RRD
|
||||
func (h *Handler) GetNetworkThroughput(c *gin.Context) {
|
||||
// Default to last 5 minutes
|
||||
durationStr := c.DefaultQuery("duration", "5m")
|
||||
duration, err := time.ParseDuration(durationStr)
|
||||
if err != nil {
|
||||
duration = 5 * time.Minute
|
||||
}
|
||||
|
||||
data, err := h.service.GetNetworkThroughput(c.Request.Context(), duration)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get network throughput", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get network throughput"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
292
backend/internal/system/rrd.go
Normal file
292
backend/internal/system/rrd.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
)
|
||||
|
||||
// RRDService handles RRD database operations for network monitoring
|
||||
type RRDService struct {
|
||||
logger *logger.Logger
|
||||
rrdDir string
|
||||
interfaceName string
|
||||
}
|
||||
|
||||
// NewRRDService creates a new RRD service
|
||||
func NewRRDService(log *logger.Logger, rrdDir string, interfaceName string) *RRDService {
|
||||
return &RRDService{
|
||||
logger: log,
|
||||
rrdDir: rrdDir,
|
||||
interfaceName: interfaceName,
|
||||
}
|
||||
}
|
||||
|
||||
// NetworkStats represents network interface statistics
|
||||
type NetworkStats struct {
|
||||
Interface string `json:"interface"`
|
||||
RxBytes uint64 `json:"rx_bytes"`
|
||||
TxBytes uint64 `json:"tx_bytes"`
|
||||
RxPackets uint64 `json:"rx_packets"`
|
||||
TxPackets uint64 `json:"tx_packets"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// GetNetworkStats reads network statistics from /proc/net/dev
|
||||
func (r *RRDService) GetNetworkStats(ctx context.Context, interfaceName string) (*NetworkStats, error) {
|
||||
data, err := os.ReadFile("/proc/net/dev")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read /proc/net/dev: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, interfaceName+":") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse line: interface: rx_bytes rx_packets ... tx_bytes tx_packets ...
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 17 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract statistics
|
||||
// Format: interface: rx_bytes rx_packets rx_errs rx_drop ... tx_bytes tx_packets ...
|
||||
rxBytes, err := strconv.ParseUint(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rxPackets, err := strconv.ParseUint(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
txBytes, err := strconv.ParseUint(parts[9], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
txPackets, err := strconv.ParseUint(parts[10], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return &NetworkStats{
|
||||
Interface: interfaceName,
|
||||
RxBytes: rxBytes,
|
||||
TxBytes: txBytes,
|
||||
RxPackets: rxPackets,
|
||||
TxPackets: txPackets,
|
||||
Timestamp: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("interface %s not found in /proc/net/dev", interfaceName)
|
||||
}
|
||||
|
||||
// InitializeRRD creates RRD database if it doesn't exist
|
||||
func (r *RRDService) InitializeRRD(ctx context.Context) error {
|
||||
// Ensure RRD directory exists
|
||||
if err := os.MkdirAll(r.rrdDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create RRD directory: %w", err)
|
||||
}
|
||||
|
||||
rrdFile := filepath.Join(r.rrdDir, fmt.Sprintf("network-%s.rrd", r.interfaceName))
|
||||
|
||||
// Check if RRD file already exists
|
||||
if _, err := os.Stat(rrdFile); err == nil {
|
||||
r.logger.Info("RRD file already exists", "file", rrdFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create RRD database
|
||||
// Use COUNTER type to track cumulative bytes, RRD will calculate rate automatically
|
||||
// DS:inbound:COUNTER:20:0:U - inbound cumulative bytes, 20s heartbeat
|
||||
// DS:outbound:COUNTER:20:0:U - outbound cumulative bytes, 20s heartbeat
|
||||
// RRA:AVERAGE:0.5:1:600 - 1 sample per step, 600 steps (100 minutes at 10s interval)
|
||||
// RRA:AVERAGE:0.5:6:700 - 6 samples per step, 700 steps (11.6 hours at 1min interval)
|
||||
// RRA:AVERAGE:0.5:60:730 - 60 samples per step, 730 steps (5 days at 1hour interval)
|
||||
// RRA:MAX:0.5:1:600 - Max values for same intervals
|
||||
// RRA:MAX:0.5:6:700
|
||||
// RRA:MAX:0.5:60:730
|
||||
cmd := exec.CommandContext(ctx, "rrdtool", "create", rrdFile,
|
||||
"--step", "10", // 10 second step
|
||||
"DS:inbound:COUNTER:20:0:U", // Inbound cumulative bytes, 20s heartbeat
|
||||
"DS:outbound:COUNTER:20:0:U", // Outbound cumulative bytes, 20s heartbeat
|
||||
"RRA:AVERAGE:0.5:1:600", // 10s resolution, 100 minutes
|
||||
"RRA:AVERAGE:0.5:6:700", // 1min resolution, 11.6 hours
|
||||
"RRA:AVERAGE:0.5:60:730", // 1hour resolution, 5 days
|
||||
"RRA:MAX:0.5:1:600", // Max values
|
||||
"RRA:MAX:0.5:6:700",
|
||||
"RRA:MAX:0.5:60:730",
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create RRD: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
r.logger.Info("RRD database created", "file", rrdFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRRD updates RRD database with new network statistics
|
||||
func (r *RRDService) UpdateRRD(ctx context.Context, stats *NetworkStats) error {
|
||||
rrdFile := filepath.Join(r.rrdDir, fmt.Sprintf("network-%s.rrd", stats.Interface))
|
||||
|
||||
// Update with cumulative byte counts (COUNTER type)
|
||||
// RRD will automatically calculate the rate (bytes per second)
|
||||
cmd := exec.CommandContext(ctx, "rrdtool", "update", rrdFile,
|
||||
fmt.Sprintf("%d:%d:%d", stats.Timestamp.Unix(), stats.RxBytes, stats.TxBytes),
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update RRD: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchRRDData fetches data from RRD database for graphing
|
||||
func (r *RRDService) FetchRRDData(ctx context.Context, startTime time.Time, endTime time.Time, resolution string) ([]NetworkDataPoint, error) {
|
||||
rrdFile := filepath.Join(r.rrdDir, fmt.Sprintf("network-%s.rrd", r.interfaceName))
|
||||
|
||||
// Check if RRD file exists
|
||||
if _, err := os.Stat(rrdFile); os.IsNotExist(err) {
|
||||
return []NetworkDataPoint{}, nil
|
||||
}
|
||||
|
||||
// Fetch data using rrdtool fetch
|
||||
// Use AVERAGE consolidation with appropriate resolution
|
||||
cmd := exec.CommandContext(ctx, "rrdtool", "fetch", rrdFile,
|
||||
"AVERAGE",
|
||||
"--start", fmt.Sprintf("%d", startTime.Unix()),
|
||||
"--end", fmt.Sprintf("%d", endTime.Unix()),
|
||||
"--resolution", resolution,
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch RRD data: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
// Parse rrdtool fetch output
|
||||
// Format:
|
||||
// inbound outbound
|
||||
// 1234567890: 1.2345678901e+06 2.3456789012e+06
|
||||
points := []NetworkDataPoint{}
|
||||
lines := strings.Split(string(output), "\n")
|
||||
|
||||
// Skip header lines
|
||||
dataStart := false
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is the data section
|
||||
if strings.Contains(line, "inbound") && strings.Contains(line, "outbound") {
|
||||
dataStart = true
|
||||
continue
|
||||
}
|
||||
|
||||
if !dataStart {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse data line: timestamp: inbound_value outbound_value
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse timestamp
|
||||
timestampStr := strings.TrimSuffix(parts[0], ":")
|
||||
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse inbound (bytes per second from COUNTER, convert to Mbps)
|
||||
inboundStr := parts[1]
|
||||
inbound, err := strconv.ParseFloat(inboundStr, 64)
|
||||
if err != nil || inbound < 0 {
|
||||
// Skip NaN or negative values
|
||||
continue
|
||||
}
|
||||
// Convert bytes per second to Mbps (bytes/s * 8 / 1000000)
|
||||
inboundMbps := inbound * 8 / 1000000
|
||||
|
||||
// Parse outbound
|
||||
outboundStr := parts[2]
|
||||
outbound, err := strconv.ParseFloat(outboundStr, 64)
|
||||
if err != nil || outbound < 0 {
|
||||
// Skip NaN or negative values
|
||||
continue
|
||||
}
|
||||
outboundMbps := outbound * 8 / 1000000
|
||||
|
||||
// Format time as MM:SS
|
||||
t := time.Unix(timestamp, 0)
|
||||
timeStr := fmt.Sprintf("%02d:%02d", t.Minute(), t.Second())
|
||||
|
||||
points = append(points, NetworkDataPoint{
|
||||
Time: timeStr,
|
||||
Inbound: inboundMbps,
|
||||
Outbound: outboundMbps,
|
||||
})
|
||||
}
|
||||
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// NetworkDataPoint represents a single data point for graphing
|
||||
type NetworkDataPoint struct {
|
||||
Time string `json:"time"`
|
||||
Inbound float64 `json:"inbound"` // Mbps
|
||||
Outbound float64 `json:"outbound"` // Mbps
|
||||
}
|
||||
|
||||
// StartCollector starts a background goroutine to periodically collect and update RRD
|
||||
func (r *RRDService) StartCollector(ctx context.Context, interval time.Duration) error {
|
||||
// Initialize RRD if needed
|
||||
if err := r.InitializeRRD(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize RRD: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Get current stats
|
||||
stats, err := r.GetNetworkStats(ctx, r.interfaceName)
|
||||
if err != nil {
|
||||
r.logger.Warn("Failed to get network stats", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update RRD with cumulative byte counts
|
||||
// RRD COUNTER type will automatically calculate rate
|
||||
if err := r.UpdateRRD(ctx, stats); err != nil {
|
||||
r.logger.Warn("Failed to update RRD", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,18 +12,98 @@ import (
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
)
|
||||
|
||||
// NTPSettings represents NTP configuration
|
||||
type NTPSettings struct {
|
||||
Timezone string `json:"timezone"`
|
||||
NTPServers []string `json:"ntp_servers"`
|
||||
}
|
||||
|
||||
// Service handles system management operations
|
||||
type Service struct {
|
||||
logger *logger.Logger
|
||||
logger *logger.Logger
|
||||
rrdService *RRDService
|
||||
}
|
||||
|
||||
// detectPrimaryInterface detects the primary network interface (first non-loopback with IP)
|
||||
func detectPrimaryInterface(ctx context.Context) string {
|
||||
// Try to get default route interface
|
||||
cmd := exec.CommandContext(ctx, "ip", "route", "show", "default")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "dev ") {
|
||||
parts := strings.Fields(line)
|
||||
for i, part := range parts {
|
||||
if part == "dev" && i+1 < len(parts) {
|
||||
iface := parts[i+1]
|
||||
if iface != "lo" {
|
||||
return iface
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: get first non-loopback interface with IP
|
||||
cmd = exec.CommandContext(ctx, "ip", "-4", "addr", "show")
|
||||
output, err = cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
// Look for interface name line (e.g., "2: ens18: <BROADCAST...")
|
||||
if len(line) > 0 && line[0] >= '0' && line[0] <= '9' && strings.Contains(line, ":") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
iface := strings.TrimSuffix(parts[1], ":")
|
||||
if iface != "" && iface != "lo" {
|
||||
// Check if this interface has an IP (next lines will have "inet")
|
||||
// For simplicity, return first non-loopback interface
|
||||
return iface
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
return "eth0"
|
||||
}
|
||||
|
||||
// NewService creates a new system service
|
||||
func NewService(log *logger.Logger) *Service {
|
||||
// Initialize RRD service for network monitoring
|
||||
rrdDir := "/var/lib/calypso/rrd"
|
||||
|
||||
// Auto-detect primary interface
|
||||
ctx := context.Background()
|
||||
interfaceName := detectPrimaryInterface(ctx)
|
||||
log.Info("Detected primary network interface", "interface", interfaceName)
|
||||
|
||||
rrdService := NewRRDService(log, rrdDir, interfaceName)
|
||||
|
||||
return &Service{
|
||||
logger: log,
|
||||
logger: log,
|
||||
rrdService: rrdService,
|
||||
}
|
||||
}
|
||||
|
||||
// StartNetworkMonitoring starts the RRD collector for network monitoring
|
||||
func (s *Service) StartNetworkMonitoring(ctx context.Context) error {
|
||||
return s.rrdService.StartCollector(ctx, 10*time.Second)
|
||||
}
|
||||
|
||||
// GetNetworkThroughput fetches network throughput data from RRD
|
||||
func (s *Service) GetNetworkThroughput(ctx context.Context, duration time.Duration) ([]NetworkDataPoint, error) {
|
||||
endTime := time.Now()
|
||||
startTime := endTime.Add(-duration)
|
||||
|
||||
// Use 10 second resolution for recent data
|
||||
return s.rrdService.FetchRRDData(ctx, startTime, endTime, "10")
|
||||
}
|
||||
|
||||
// ServiceStatus represents a systemd service status
|
||||
type ServiceStatus struct {
|
||||
Name string `json:"name"`
|
||||
@@ -35,31 +116,37 @@ type ServiceStatus struct {
|
||||
|
||||
// GetServiceStatus retrieves the status of a systemd service
|
||||
func (s *Service) GetServiceStatus(ctx context.Context, serviceName string) (*ServiceStatus, error) {
|
||||
cmd := exec.CommandContext(ctx, "systemctl", "show", serviceName,
|
||||
"--property=ActiveState,SubState,LoadState,Description,ActiveEnterTimestamp",
|
||||
"--value", "--no-pager")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get service status: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) < 4 {
|
||||
return nil, fmt.Errorf("invalid service status output")
|
||||
}
|
||||
|
||||
status := &ServiceStatus{
|
||||
Name: serviceName,
|
||||
ActiveState: strings.TrimSpace(lines[0]),
|
||||
SubState: strings.TrimSpace(lines[1]),
|
||||
LoadState: strings.TrimSpace(lines[2]),
|
||||
Description: strings.TrimSpace(lines[3]),
|
||||
Name: serviceName,
|
||||
}
|
||||
|
||||
// Parse timestamp if available
|
||||
if len(lines) > 4 && lines[4] != "" {
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", strings.TrimSpace(lines[4])); err == nil {
|
||||
status.Since = t
|
||||
// Get each property individually to ensure correct parsing
|
||||
properties := map[string]*string{
|
||||
"ActiveState": &status.ActiveState,
|
||||
"SubState": &status.SubState,
|
||||
"LoadState": &status.LoadState,
|
||||
"Description": &status.Description,
|
||||
}
|
||||
|
||||
for prop, target := range properties {
|
||||
cmd := exec.CommandContext(ctx, "systemctl", "show", serviceName, "--property", prop, "--value", "--no-pager")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to get property", "service", serviceName, "property", prop, "error", err)
|
||||
continue
|
||||
}
|
||||
*target = strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// Get timestamp if available
|
||||
cmd := exec.CommandContext(ctx, "systemctl", "show", serviceName, "--property", "ActiveEnterTimestamp", "--value", "--no-pager")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
timestamp := strings.TrimSpace(string(output))
|
||||
if timestamp != "" {
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", timestamp); err == nil {
|
||||
status.Since = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +156,15 @@ func (s *Service) GetServiceStatus(ctx context.Context, serviceName string) (*Se
|
||||
// ListServices lists all Calypso-related services
|
||||
func (s *Service) ListServices(ctx context.Context) ([]ServiceStatus, error) {
|
||||
services := []string{
|
||||
"ssh",
|
||||
"sshd",
|
||||
"smbd",
|
||||
"iscsi-scst",
|
||||
"nfs-server",
|
||||
"nfs",
|
||||
"mhvtl",
|
||||
"calypso-api",
|
||||
"scst",
|
||||
"iscsi-scst",
|
||||
"mhvtl",
|
||||
"postgresql",
|
||||
}
|
||||
|
||||
@@ -128,6 +220,108 @@ func (s *Service) GetJournalLogs(ctx context.Context, serviceName string, lines
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// SystemLogEntry represents a parsed system log entry
|
||||
type SystemLogEntry struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Source string `json:"source"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// GetSystemLogs retrieves recent system logs from journalctl
|
||||
func (s *Service) GetSystemLogs(ctx context.Context, limit int) ([]SystemLogEntry, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30 // Default to 30 logs
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "journalctl",
|
||||
"-n", fmt.Sprintf("%d", limit),
|
||||
"-o", "json",
|
||||
"--no-pager",
|
||||
"--since", "1 hour ago") // Only get logs from last hour
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get system logs: %w", err)
|
||||
}
|
||||
|
||||
var logs []SystemLogEntry
|
||||
linesOutput := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, line := range linesOutput {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var logEntry map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse timestamp (__REALTIME_TIMESTAMP is in microseconds)
|
||||
var timeStr string
|
||||
if timestamp, ok := logEntry["__REALTIME_TIMESTAMP"].(float64); ok {
|
||||
// Convert microseconds to nanoseconds for time.Unix (1 microsecond = 1000 nanoseconds)
|
||||
t := time.Unix(0, int64(timestamp)*1000)
|
||||
timeStr = t.Format("15:04:05")
|
||||
} else if timestamp, ok := logEntry["_SOURCE_REALTIME_TIMESTAMP"].(float64); ok {
|
||||
t := time.Unix(0, int64(timestamp)*1000)
|
||||
timeStr = t.Format("15:04:05")
|
||||
} else {
|
||||
timeStr = time.Now().Format("15:04:05")
|
||||
}
|
||||
|
||||
// Parse log level (priority)
|
||||
level := "INFO"
|
||||
if priority, ok := logEntry["PRIORITY"].(float64); ok {
|
||||
switch int(priority) {
|
||||
case 0: // emerg
|
||||
level = "EMERG"
|
||||
case 1, 2, 3: // alert, crit, err
|
||||
level = "ERROR"
|
||||
case 4: // warning
|
||||
level = "WARN"
|
||||
case 5: // notice
|
||||
level = "NOTICE"
|
||||
case 6: // info
|
||||
level = "INFO"
|
||||
case 7: // debug
|
||||
level = "DEBUG"
|
||||
}
|
||||
}
|
||||
|
||||
// Parse source (systemd unit or syslog identifier)
|
||||
source := "system"
|
||||
if unit, ok := logEntry["_SYSTEMD_UNIT"].(string); ok && unit != "" {
|
||||
// Remove .service suffix if present
|
||||
source = strings.TrimSuffix(unit, ".service")
|
||||
} else if ident, ok := logEntry["SYSLOG_IDENTIFIER"].(string); ok && ident != "" {
|
||||
source = ident
|
||||
} else if comm, ok := logEntry["_COMM"].(string); ok && comm != "" {
|
||||
source = comm
|
||||
}
|
||||
|
||||
// Parse message
|
||||
message := ""
|
||||
if msg, ok := logEntry["MESSAGE"].(string); ok {
|
||||
message = msg
|
||||
}
|
||||
|
||||
if message != "" {
|
||||
logs = append(logs, SystemLogEntry{
|
||||
Time: timeStr,
|
||||
Level: level,
|
||||
Source: source,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse to get newest first
|
||||
for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 {
|
||||
logs[i], logs[j] = logs[j], logs[i]
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// GenerateSupportBundle generates a diagnostic support bundle
|
||||
func (s *Service) GenerateSupportBundle(ctx context.Context, outputPath string) error {
|
||||
// Create bundle directory
|
||||
@@ -183,6 +377,9 @@ type NetworkInterface struct {
|
||||
Status string `json:"status"` // "Connected" or "Down"
|
||||
Speed string `json:"speed"` // e.g., "10 Gbps", "1 Gbps"
|
||||
Role string `json:"role"` // "Management", "ISCSI", or empty
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
DNS1 string `json:"dns1,omitempty"`
|
||||
DNS2 string `json:"dns2,omitempty"`
|
||||
}
|
||||
|
||||
// ListNetworkInterfaces lists all network interfaces
|
||||
@@ -297,6 +494,103 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface
|
||||
}
|
||||
}
|
||||
|
||||
// Get default gateway for each interface
|
||||
cmd = exec.CommandContext(ctx, "ip", "route", "show")
|
||||
output, err = cmd.Output()
|
||||
if err == nil {
|
||||
lines = strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse default route: "default via 10.10.14.1 dev ens18"
|
||||
if strings.HasPrefix(line, "default via ") {
|
||||
parts := strings.Fields(line)
|
||||
// Find "via" and "dev" in the parts
|
||||
var gateway string
|
||||
var ifaceName string
|
||||
for i, part := range parts {
|
||||
if part == "via" && i+1 < len(parts) {
|
||||
gateway = parts[i+1]
|
||||
}
|
||||
if part == "dev" && i+1 < len(parts) {
|
||||
ifaceName = parts[i+1]
|
||||
}
|
||||
}
|
||||
if gateway != "" && ifaceName != "" {
|
||||
if iface, exists := interfaceMap[ifaceName]; exists {
|
||||
iface.Gateway = gateway
|
||||
s.logger.Info("Set default gateway for interface", "name", ifaceName, "gateway", gateway)
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(line, " via ") && strings.Contains(line, " dev ") {
|
||||
// Parse network route: "10.10.14.0/24 via 10.10.14.1 dev ens18"
|
||||
// Or: "192.168.1.0/24 via 192.168.1.1 dev eth0"
|
||||
parts := strings.Fields(line)
|
||||
var gateway string
|
||||
var ifaceName string
|
||||
for i, part := range parts {
|
||||
if part == "via" && i+1 < len(parts) {
|
||||
gateway = parts[i+1]
|
||||
}
|
||||
if part == "dev" && i+1 < len(parts) {
|
||||
ifaceName = parts[i+1]
|
||||
}
|
||||
}
|
||||
// Only set gateway if it's not already set (prefer default route)
|
||||
if gateway != "" && ifaceName != "" {
|
||||
if iface, exists := interfaceMap[ifaceName]; exists {
|
||||
if iface.Gateway == "" {
|
||||
iface.Gateway = gateway
|
||||
s.logger.Info("Set gateway from network route for interface", "name", ifaceName, "gateway", gateway)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.logger.Warn("Failed to get routes", "error", err)
|
||||
}
|
||||
|
||||
// Get DNS servers from systemd-resolved or /etc/resolv.conf
|
||||
// Try systemd-resolved first
|
||||
cmd = exec.CommandContext(ctx, "systemd-resolve", "--status")
|
||||
output, err = cmd.Output()
|
||||
dnsServers := []string{}
|
||||
if err == nil {
|
||||
// Parse DNS from systemd-resolve output
|
||||
lines = strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "DNS Servers:") {
|
||||
// Format: "DNS Servers: 8.8.8.8 8.8.4.4"
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 3 {
|
||||
dnsServers = parts[2:]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to /etc/resolv.conf
|
||||
data, err := os.ReadFile("/etc/resolv.conf")
|
||||
if err == nil {
|
||||
lines = strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "nameserver ") {
|
||||
dns := strings.TrimPrefix(line, "nameserver ")
|
||||
dns = strings.TrimSpace(dns)
|
||||
if dns != "" {
|
||||
dnsServers = append(dnsServers, dns)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to slice
|
||||
var interfaces []NetworkInterface
|
||||
s.logger.Debug("Converting interface map to slice", "map_size", len(interfaceMap))
|
||||
@@ -319,6 +613,14 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface
|
||||
}
|
||||
}
|
||||
|
||||
// Set DNS servers (use first two if available)
|
||||
if len(dnsServers) > 0 {
|
||||
iface.DNS1 = dnsServers[0]
|
||||
}
|
||||
if len(dnsServers) > 1 {
|
||||
iface.DNS2 = dnsServers[1]
|
||||
}
|
||||
|
||||
// Determine role based on interface name or IP (simple heuristic)
|
||||
// You can enhance this with configuration file or database lookup
|
||||
if strings.Contains(iface.Name, "eth") || strings.Contains(iface.Name, "ens") {
|
||||
@@ -345,3 +647,227 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface
|
||||
s.logger.Info("Listed network interfaces", "count", len(interfaces))
|
||||
return interfaces, nil
|
||||
}
|
||||
|
||||
// UpdateNetworkInterfaceRequest represents the request to update a network interface
|
||||
type UpdateNetworkInterfaceRequest struct {
|
||||
IPAddress string `json:"ip_address"`
|
||||
Subnet string `json:"subnet"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
DNS1 string `json:"dns1,omitempty"`
|
||||
DNS2 string `json:"dns2,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateNetworkInterface updates network interface configuration
|
||||
func (s *Service) UpdateNetworkInterface(ctx context.Context, ifaceName string, req UpdateNetworkInterfaceRequest) (*NetworkInterface, error) {
|
||||
// Validate interface exists
|
||||
cmd := exec.CommandContext(ctx, "ip", "link", "show", ifaceName)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("interface %s not found: %w", ifaceName, err)
|
||||
}
|
||||
|
||||
// Remove existing IP address if any
|
||||
cmd = exec.CommandContext(ctx, "ip", "addr", "flush", "dev", ifaceName)
|
||||
cmd.Run() // Ignore error, interface might not have IP
|
||||
|
||||
// Set new IP address and subnet
|
||||
ipWithSubnet := fmt.Sprintf("%s/%s", req.IPAddress, req.Subnet)
|
||||
cmd = exec.CommandContext(ctx, "ip", "addr", "add", ipWithSubnet, "dev", ifaceName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to set IP address", "interface", ifaceName, "error", err, "output", string(output))
|
||||
return nil, fmt.Errorf("failed to set IP address: %w", err)
|
||||
}
|
||||
|
||||
// Remove existing default route if any
|
||||
cmd = exec.CommandContext(ctx, "ip", "route", "del", "default")
|
||||
cmd.Run() // Ignore error, might not exist
|
||||
|
||||
// Set gateway if provided
|
||||
if req.Gateway != "" {
|
||||
cmd = exec.CommandContext(ctx, "ip", "route", "add", "default", "via", req.Gateway, "dev", ifaceName)
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to set gateway", "interface", ifaceName, "error", err, "output", string(output))
|
||||
return nil, fmt.Errorf("failed to set gateway: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update DNS in systemd-resolved or /etc/resolv.conf
|
||||
if req.DNS1 != "" || req.DNS2 != "" {
|
||||
// Try using systemd-resolve first
|
||||
cmd = exec.CommandContext(ctx, "systemd-resolve", "--status")
|
||||
if cmd.Run() == nil {
|
||||
// systemd-resolve is available, use it
|
||||
dnsServers := []string{}
|
||||
if req.DNS1 != "" {
|
||||
dnsServers = append(dnsServers, req.DNS1)
|
||||
}
|
||||
if req.DNS2 != "" {
|
||||
dnsServers = append(dnsServers, req.DNS2)
|
||||
}
|
||||
if len(dnsServers) > 0 {
|
||||
// Use resolvectl to set DNS (newer systemd)
|
||||
cmd = exec.CommandContext(ctx, "resolvectl", "dns", ifaceName, strings.Join(dnsServers, " "))
|
||||
if cmd.Run() != nil {
|
||||
// Fallback to systemd-resolve
|
||||
cmd = exec.CommandContext(ctx, "systemd-resolve", "--interface", ifaceName, "--set-dns", strings.Join(dnsServers, " "))
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to set DNS via systemd-resolve", "error", err, "output", string(output))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: update /etc/resolv.conf
|
||||
resolvContent := "# Generated by Calypso\n"
|
||||
if req.DNS1 != "" {
|
||||
resolvContent += fmt.Sprintf("nameserver %s\n", req.DNS1)
|
||||
}
|
||||
if req.DNS2 != "" {
|
||||
resolvContent += fmt.Sprintf("nameserver %s\n", req.DNS2)
|
||||
}
|
||||
|
||||
tmpPath := "/tmp/resolv.conf." + fmt.Sprintf("%d", time.Now().Unix())
|
||||
if err := os.WriteFile(tmpPath, []byte(resolvContent), 0644); err != nil {
|
||||
s.logger.Warn("Failed to write temporary resolv.conf", "error", err)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("mv %s /etc/resolv.conf", tmpPath))
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to update /etc/resolv.conf", "error", err, "output", string(output))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bring interface up
|
||||
cmd = exec.CommandContext(ctx, "ip", "link", "set", ifaceName, "up")
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to bring interface up", "interface", ifaceName, "error", err, "output", string(output))
|
||||
}
|
||||
|
||||
// Return updated interface
|
||||
updatedIface := &NetworkInterface{
|
||||
Name: ifaceName,
|
||||
IPAddress: req.IPAddress,
|
||||
Subnet: req.Subnet,
|
||||
Gateway: req.Gateway,
|
||||
DNS1: req.DNS1,
|
||||
DNS2: req.DNS2,
|
||||
Role: req.Role,
|
||||
Status: "Connected",
|
||||
Speed: "Unknown", // Will be updated on next list
|
||||
}
|
||||
|
||||
s.logger.Info("Updated network interface", "interface", ifaceName, "ip", req.IPAddress, "subnet", req.Subnet)
|
||||
return updatedIface, nil
|
||||
}
|
||||
|
||||
// SaveNTPSettings saves NTP configuration to the OS
|
||||
func (s *Service) SaveNTPSettings(ctx context.Context, settings NTPSettings) error {
|
||||
// Set timezone using timedatectl
|
||||
if settings.Timezone != "" {
|
||||
cmd := exec.CommandContext(ctx, "timedatectl", "set-timezone", settings.Timezone)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to set timezone", "timezone", settings.Timezone, "error", err, "output", string(output))
|
||||
return fmt.Errorf("failed to set timezone: %w", err)
|
||||
}
|
||||
s.logger.Info("Timezone set", "timezone", settings.Timezone)
|
||||
}
|
||||
|
||||
// Configure NTP servers in systemd-timesyncd
|
||||
if len(settings.NTPServers) > 0 {
|
||||
configPath := "/etc/systemd/timesyncd.conf"
|
||||
|
||||
// Build config content
|
||||
configContent := "[Time]\n"
|
||||
configContent += "NTP="
|
||||
for i, server := range settings.NTPServers {
|
||||
if i > 0 {
|
||||
configContent += " "
|
||||
}
|
||||
configContent += server
|
||||
}
|
||||
configContent += "\n"
|
||||
|
||||
// Write to temporary file first, then move to final location (requires root)
|
||||
tmpPath := "/tmp/timesyncd.conf." + fmt.Sprintf("%d", time.Now().Unix())
|
||||
if err := os.WriteFile(tmpPath, []byte(configContent), 0644); err != nil {
|
||||
s.logger.Error("Failed to write temporary NTP config", "error", err)
|
||||
return fmt.Errorf("failed to write temporary NTP configuration: %w", err)
|
||||
}
|
||||
|
||||
// Move to final location using sudo (requires root privileges)
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("mv %s %s", tmpPath, configPath))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to move NTP config", "error", err, "output", string(output))
|
||||
os.Remove(tmpPath) // Clean up temp file
|
||||
return fmt.Errorf("failed to move NTP configuration: %w", err)
|
||||
}
|
||||
|
||||
// Restart systemd-timesyncd to apply changes
|
||||
cmd = exec.CommandContext(ctx, "systemctl", "restart", "systemd-timesyncd")
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to restart systemd-timesyncd", "error", err, "output", string(output))
|
||||
return fmt.Errorf("failed to restart systemd-timesyncd: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("NTP servers configured", "servers", settings.NTPServers)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNTPSettings retrieves current NTP configuration from the OS
|
||||
func (s *Service) GetNTPSettings(ctx context.Context) (*NTPSettings, error) {
|
||||
settings := &NTPSettings{
|
||||
NTPServers: []string{},
|
||||
}
|
||||
|
||||
// Get current timezone using timedatectl
|
||||
cmd := exec.CommandContext(ctx, "timedatectl", "show", "--property=Timezone", "--value")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to get timezone", "error", err)
|
||||
settings.Timezone = "Etc/UTC" // Default fallback
|
||||
} else {
|
||||
settings.Timezone = strings.TrimSpace(string(output))
|
||||
if settings.Timezone == "" {
|
||||
settings.Timezone = "Etc/UTC"
|
||||
}
|
||||
}
|
||||
|
||||
// Read NTP servers from systemd-timesyncd config
|
||||
configPath := "/etc/systemd/timesyncd.conf"
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to read NTP config", "error", err)
|
||||
// Default NTP servers if config file doesn't exist
|
||||
settings.NTPServers = []string{"pool.ntp.org", "time.google.com"}
|
||||
} else {
|
||||
// Parse NTP servers from config file
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "NTP=") {
|
||||
ntpLine := strings.TrimPrefix(line, "NTP=")
|
||||
if ntpLine != "" {
|
||||
servers := strings.Fields(ntpLine)
|
||||
settings.NTPServers = servers
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no NTP servers found in config, use defaults
|
||||
if len(settings.NTPServers) == 0 {
|
||||
settings.NTPServers = []string{"pool.ntp.org", "time.google.com"}
|
||||
}
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
1
bacula-config
Symbolic link
1
bacula-config
Symbolic link
@@ -0,0 +1 @@
|
||||
/etc/bacula
|
||||
354
docs/bacula-vtl-troubleshooting.md
Normal file
354
docs/bacula-vtl-troubleshooting.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# Bacula VTL Integration - Root Cause Analysis & Troubleshooting
|
||||
|
||||
## Issue Summary
|
||||
Bacula Storage Daemon was unable to read slots from mhVTL (Virtual Tape Library) autochanger devices, reporting "Device has 0 slots" despite mtx-changer script working correctly when called manually.
|
||||
|
||||
## Environment
|
||||
- **OS**: Ubuntu Linux
|
||||
- **Bacula Version**: 13.0.4
|
||||
- **VTL**: mhVTL (Virtual Tape Library)
|
||||
- **Autochangers**:
|
||||
- Quantum Scalar i500 (4 drives, 43 slots)
|
||||
- Quantum Scalar i40 (4 drives, 44 slots)
|
||||
- **Tape Drives**: 8x QUANTUM ULTRIUM-HH8 (LTO-8)
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Primary Issues Identified
|
||||
|
||||
#### 1. **Incorrect Tape Device Type**
|
||||
**Problem**: Using rewinding tape devices (`/dev/st*`) instead of non-rewinding devices (`/dev/nst*`)
|
||||
|
||||
**Impact**: Tape would rewind after each operation, causing data loss and operational failures
|
||||
|
||||
**Solution**: Changed all Archive Device directives from `/dev/st*` to `/dev/nst*`
|
||||
|
||||
```diff
|
||||
Device {
|
||||
Name = Drive-0
|
||||
- Archive Device = /dev/st0
|
||||
+ Archive Device = /dev/nst0
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. **Missing Drive Index Parameter**
|
||||
**Problem**: Device configurations lacked Drive Index parameter
|
||||
|
||||
**Impact**: Bacula couldn't properly identify which physical drive in the autochanger to use
|
||||
|
||||
**Solution**: Added Drive Index (0-3) to each Device resource
|
||||
|
||||
```diff
|
||||
Device {
|
||||
Name = Drive-0
|
||||
+ Drive Index = 0
|
||||
Archive Device = /dev/nst0
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. **Incorrect AlwaysOpen Setting**
|
||||
**Problem**: AlwaysOpen was set to `no`
|
||||
|
||||
**Impact**: Device wouldn't remain open, causing connection issues with VTL
|
||||
|
||||
**Solution**: Changed AlwaysOpen to `yes` for all tape devices
|
||||
|
||||
```diff
|
||||
Device {
|
||||
Name = Drive-0
|
||||
- AlwaysOpen = no
|
||||
+ AlwaysOpen = yes
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. **Wrong Changer Device Path**
|
||||
**Problem**: Using `/dev/sch*` (medium changer device) instead of `/dev/sg*` (generic SCSI device)
|
||||
|
||||
**Impact**: bacula user couldn't access the changer due to permission issues (cdrom group vs tape group)
|
||||
|
||||
**Solution**: Changed Changer Device to use sg devices
|
||||
|
||||
```diff
|
||||
Autochanger {
|
||||
Name = Scalar-i500
|
||||
- Changer Device = /dev/sch0
|
||||
+ Changer Device = /dev/sg7
|
||||
}
|
||||
```
|
||||
|
||||
**Device Mapping**:
|
||||
- `/dev/sch0` → `/dev/sg7` (Scalar i500)
|
||||
- `/dev/sch1` → `/dev/sg8` (Scalar i40)
|
||||
|
||||
#### 5. **Missing User Permissions**
|
||||
**Problem**: bacula user not in required groups for device access
|
||||
|
||||
**Impact**: "Permission denied" errors when accessing tape and changer devices
|
||||
|
||||
**Solution**: Added bacula user to tape and cdrom groups
|
||||
|
||||
```bash
|
||||
usermod -a -G tape,cdrom bacula
|
||||
systemctl restart bacula-sd
|
||||
```
|
||||
|
||||
#### 6. **Incorrect Storage Resource Configuration**
|
||||
**Problem**: Storage resource in Director config referenced autochanger name instead of individual drives
|
||||
|
||||
**Impact**: Bacula couldn't properly communicate with individual tape drives
|
||||
|
||||
**Solution**: Listed all drives explicitly in Storage resource
|
||||
|
||||
```diff
|
||||
Storage {
|
||||
Name = Scalar-i500
|
||||
- Device = Scalar-i500
|
||||
+ Device = Drive-0
|
||||
+ Device = Drive-1
|
||||
+ Device = Drive-2
|
||||
+ Device = Drive-3
|
||||
Autochanger = Scalar-i500
|
||||
}
|
||||
```
|
||||
|
||||
#### 7. **mtx-changer List Output Format**
|
||||
**Problem**: Script output format didn't match Bacula's expected format
|
||||
|
||||
**Impact**: "Invalid Slot number" errors, preventing volume labeling
|
||||
|
||||
**Original Output**: `1 Full:VolumeTag=E01001L8`
|
||||
**Expected Output**: `1:E01001L8`
|
||||
|
||||
**Solution**: Fixed sed pattern in list command
|
||||
|
||||
```bash
|
||||
# Original (incorrect)
|
||||
list)
|
||||
${MTX} -f $ctl status | grep "Storage Element" | grep "Full" | awk '{print $3 $4}' | sed 's/:/ /'
|
||||
;;
|
||||
|
||||
# Fixed
|
||||
list)
|
||||
${MTX} -f $ctl status | grep "Storage Element" | grep "Full" | awk '{print $3 $4}' | sed 's/:Full:VolumeTag=/:/'
|
||||
;;
|
||||
```
|
||||
|
||||
## Troubleshooting Steps
|
||||
|
||||
### Step 1: Verify mtx-changer Script Works Manually
|
||||
```bash
|
||||
# Test slots command
|
||||
/usr/lib/bacula/scripts/mtx-changer /dev/sg7 slots
|
||||
# Expected output: 43
|
||||
|
||||
# Test list command
|
||||
/usr/lib/bacula/scripts/mtx-changer /dev/sg7 list
|
||||
# Expected output: 1:E01001L8, 2:E01002L8, etc.
|
||||
```
|
||||
|
||||
### Step 2: Test as bacula User
|
||||
```bash
|
||||
# Test if bacula user can access devices
|
||||
su -s /bin/bash bacula -c "/usr/lib/bacula/scripts/mtx-changer /dev/sg7 slots"
|
||||
|
||||
# If permission denied, check groups
|
||||
groups bacula
|
||||
# Should include: bacula tape cdrom
|
||||
```
|
||||
|
||||
### Step 3: Verify Device Permissions
|
||||
```bash
|
||||
# Check changer devices
|
||||
ls -l /dev/sch* /dev/sg7 /dev/sg8
|
||||
# sg devices should be in tape group
|
||||
|
||||
# Check tape devices
|
||||
ls -l /dev/nst*
|
||||
# Should be in tape group with rw permissions
|
||||
```
|
||||
|
||||
### Step 4: Test Bacula Storage Daemon Connection
|
||||
```bash
|
||||
# From bconsole
|
||||
echo "status storage=Scalar-i500" | bconsole
|
||||
|
||||
# Should show autochanger and drives
|
||||
```
|
||||
|
||||
### Step 5: Update Slots
|
||||
```bash
|
||||
echo -e "update slots storage=Scalar-i500\n0\n" | bconsole
|
||||
|
||||
# Should show: Device "Drive-0" has 43 slots
|
||||
# NOT: Device has 0 slots
|
||||
```
|
||||
|
||||
### Step 6: Label Tapes
|
||||
```bash
|
||||
echo -e "label barcodes storage=Scalar-i500 pool=Default\n0\nyes\n" | bconsole
|
||||
|
||||
# Should successfully label tapes using barcodes
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### /etc/bacula/bacula-sd.conf (Storage Daemon)
|
||||
```bash
|
||||
Autochanger {
|
||||
Name = Scalar-i500
|
||||
Device = Drive-0, Drive-1, Drive-2, Drive-3
|
||||
Changer Command = "/usr/lib/bacula/scripts/mtx-changer %c %o %S %a %d"
|
||||
Changer Device = /dev/sg7
|
||||
}
|
||||
|
||||
Device {
|
||||
Name = Drive-0
|
||||
Drive Index = 0
|
||||
Changer Device = /dev/sg7
|
||||
Media Type = LTO-8
|
||||
Archive Device = /dev/nst0
|
||||
AutomaticMount = yes
|
||||
AlwaysOpen = yes
|
||||
RemovableMedia = yes
|
||||
RandomAccess = no
|
||||
AutoChanger = yes
|
||||
Maximum Concurrent Jobs = 1
|
||||
}
|
||||
```
|
||||
|
||||
### /etc/bacula/bacula-dir.conf (Director)
|
||||
```bash
|
||||
Storage {
|
||||
Name = Scalar-i500
|
||||
Address = localhost
|
||||
SDPort = 9103
|
||||
Password = "QJQPnZ5Q5p6D73RcvR7ksrOm9UG3mAhvV"
|
||||
Device = Drive-0
|
||||
Device = Drive-1
|
||||
Device = Drive-2
|
||||
Device = Drive-3
|
||||
Media Type = LTO-8
|
||||
Autochanger = Scalar-i500
|
||||
Maximum Concurrent Jobs = 4
|
||||
}
|
||||
```
|
||||
|
||||
### /usr/lib/bacula/scripts/mtx-changer
|
||||
```bash
|
||||
#!/bin/sh
|
||||
MTX=/usr/sbin/mtx
|
||||
|
||||
ctl=$1
|
||||
cmd="$2"
|
||||
slot=$3
|
||||
device=$4
|
||||
drive=$5
|
||||
|
||||
case "$cmd" in
|
||||
loaded)
|
||||
${MTX} -f $ctl status | grep "Data Transfer Element $slot:Full" >/dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
${MTX} -f $ctl status | grep "Data Transfer Element $slot:Full" | awk '{print $7}' | sed 's/.*=//'
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
;;
|
||||
|
||||
load)
|
||||
${MTX} -f $ctl load $slot $drive
|
||||
;;
|
||||
|
||||
unload)
|
||||
${MTX} -f $ctl unload $slot $drive
|
||||
;;
|
||||
|
||||
list)
|
||||
${MTX} -f $ctl status | grep "Storage Element" | grep "Full" | awk '{print $3 $4}' | sed 's/:Full:VolumeTag=/:/'
|
||||
;;
|
||||
|
||||
slots)
|
||||
${MTX} -f $ctl status | grep "Storage Changer" | awk '{print $5}'
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Invalid command: $cmd"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
```
|
||||
|
||||
## Verification Commands
|
||||
|
||||
### Check Device Mapping
|
||||
```bash
|
||||
lsscsi -g | grep -E "mediumx|tape"
|
||||
```
|
||||
|
||||
### Check VTL Services
|
||||
```bash
|
||||
systemctl list-units 'vtl*'
|
||||
```
|
||||
|
||||
### Test Manual Tape Load
|
||||
```bash
|
||||
# Load tape to drive
|
||||
mtx -f /dev/sg7 load 1 0
|
||||
|
||||
# Check drive status
|
||||
mt -f /dev/nst0 status
|
||||
|
||||
# Unload tape
|
||||
mtx -f /dev/sg7 unload 1 0
|
||||
```
|
||||
|
||||
### List Labeled Volumes
|
||||
```bash
|
||||
echo "list volumes pool=Default" | bconsole
|
||||
```
|
||||
|
||||
## Common Errors and Solutions
|
||||
|
||||
### Error: "Device has 0 slots"
|
||||
**Cause**: Wrong changer device or permission issues
|
||||
**Solution**: Use /dev/sg* devices and verify bacula user in tape/cdrom groups
|
||||
|
||||
### Error: "Permission denied" accessing /dev/sch0
|
||||
**Cause**: bacula user not in cdrom group
|
||||
**Solution**: `usermod -a -G cdrom bacula && systemctl restart bacula-sd`
|
||||
|
||||
### Error: "Invalid Slot number"
|
||||
**Cause**: mtx-changer list output format incorrect
|
||||
**Solution**: Fix sed pattern to output `slot:volumetag` format
|
||||
|
||||
### Error: "No medium found" after successful load
|
||||
**Cause**: Using rewinding devices (/dev/st*) or AlwaysOpen=no
|
||||
**Solution**: Use /dev/nst* and set AlwaysOpen=yes
|
||||
|
||||
### Error: "READ ELEMENT STATUS Command Failed"
|
||||
**Cause**: Permission issue or VTL service problem
|
||||
**Solution**: Check user permissions and restart vtllibrary service
|
||||
|
||||
## Results
|
||||
|
||||
### Scalar i500 (WORKING)
|
||||
- ✅ 43 slots detected
|
||||
- ✅ 20 tapes successfully labeled (E01001L8 - E01020L8)
|
||||
- ✅ Autochanger operations functional
|
||||
- ✅ Ready for backup jobs
|
||||
|
||||
### Scalar i40 (ISSUE)
|
||||
- ⚠️ 44 slots detected
|
||||
- ❌ Hardware Error during tape load operations
|
||||
- ❌ 0 tapes labeled
|
||||
- **Status**: Requires mhVTL configuration investigation or system restart
|
||||
|
||||
## References
|
||||
- Bacula Documentation: https://www.bacula.org/
|
||||
- Article: "Using Bacula with mhVTL" - https://karellen.blogspot.com/2012/02/using-bacula-with-mhvtl.html
|
||||
- mhVTL Project: https://github.com/markh794/mhvtl
|
||||
|
||||
## Date
|
||||
Created: 2025-12-31
|
||||
Author: Warp AI Agent
|
||||
344
docs/healthcheck-script.md
Normal file
344
docs/healthcheck-script.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# Calypso Appliance Health Check Script
|
||||
|
||||
## Overview
|
||||
Comprehensive health check script for all Calypso Appliance components. Performs automated checks across system resources, services, network, storage, and backup infrastructure.
|
||||
|
||||
## Installation
|
||||
Script location: `/usr/local/bin/calypso-healthcheck`
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
```bash
|
||||
# Run health check (requires root)
|
||||
calypso-healthcheck
|
||||
|
||||
# Run and save to specific location
|
||||
calypso-healthcheck 2>&1 | tee /root/healthcheck-$(date +%Y%m%d).log
|
||||
```
|
||||
|
||||
### Exit Codes
|
||||
- `0` - All checks passed (100% healthy)
|
||||
- `1` - Healthy with warnings (some non-critical issues)
|
||||
- `2` - Degraded (80%+ checks passed, some failures)
|
||||
- `3` - Critical (less than 80% checks passed)
|
||||
|
||||
### Automated Checks
|
||||
|
||||
#### System Resources (4 checks)
|
||||
- Root filesystem usage (threshold: 80%)
|
||||
- /var filesystem usage (threshold: 80%)
|
||||
- Memory usage (threshold: 90%)
|
||||
- CPU load average
|
||||
|
||||
#### Database Services (2 checks)
|
||||
- PostgreSQL service status
|
||||
- Database presence (calypso, bacula)
|
||||
|
||||
#### Calypso Application (7 checks)
|
||||
- calypso-api service
|
||||
- calypso-frontend service
|
||||
- calypso-logger service
|
||||
- API port 8443
|
||||
- Frontend port 3000
|
||||
- API health endpoint
|
||||
- Frontend health endpoint
|
||||
|
||||
#### Backup Services - Bacula (8 checks)
|
||||
- bacula-director service
|
||||
- bacula-fd service
|
||||
- bacula-sd service
|
||||
- Director bconsole connectivity
|
||||
- Storage (Scalar-i500) accessibility
|
||||
- Director port 9101
|
||||
- FD port 9102
|
||||
- SD port 9103
|
||||
|
||||
#### Virtual Tape Library - mhVTL (4 checks)
|
||||
- mhvtl.target status
|
||||
- vtllibrary@10 (Scalar i500)
|
||||
- vtllibrary@30 (Scalar i40)
|
||||
- VTL device count (2 changers, 8 tape drives)
|
||||
- Scalar i500 slots detection
|
||||
|
||||
#### Storage Protocols (9 checks)
|
||||
- NFS server service
|
||||
- Samba (smbd) service
|
||||
- NetBIOS (nmbd) service
|
||||
- SCST service
|
||||
- iSCSI target service
|
||||
- NFS port 2049
|
||||
- SMB port 445
|
||||
- NetBIOS port 139
|
||||
- iSCSI port 3260
|
||||
|
||||
#### Monitoring & Management (2 checks)
|
||||
- SNMP daemon
|
||||
- SNMP port 161
|
||||
|
||||
#### Network Connectivity (2 checks)
|
||||
- Internet connectivity (ping 8.8.8.8)
|
||||
- Network manager status
|
||||
|
||||
**Total: 39+ automated checks**
|
||||
|
||||
## Output Format
|
||||
|
||||
### Console Output
|
||||
- Color-coded status indicators:
|
||||
- ✓ Green = Passed
|
||||
- ⚠ Yellow = Warning
|
||||
- ✗ Red = Failed
|
||||
|
||||
### Example Output
|
||||
```
|
||||
==========================================
|
||||
CALYPSO APPLIANCE HEALTH CHECK
|
||||
==========================================
|
||||
Date: 2025-12-31 01:46:27
|
||||
Hostname: calypso
|
||||
Uptime: up 6 days, 2 hours, 50 minutes
|
||||
Log file: /var/log/calypso-healthcheck-20251231-014627.log
|
||||
|
||||
========================================
|
||||
SYSTEM RESOURCES
|
||||
========================================
|
||||
✓ Root filesystem (18% used)
|
||||
✓ Var filesystem (18% used)
|
||||
✓ Memory usage (49% used, 8206MB available)
|
||||
✓ CPU load average (2.18, 8 cores)
|
||||
|
||||
...
|
||||
|
||||
========================================
|
||||
HEALTH CHECK SUMMARY
|
||||
========================================
|
||||
|
||||
Total Checks: 39
|
||||
Passed: 35
|
||||
Warnings: 0
|
||||
Failed: 4
|
||||
|
||||
⚠ OVERALL STATUS: DEGRADED (89%)
|
||||
```
|
||||
|
||||
### Log Files
|
||||
All checks are logged to: `/var/log/calypso-healthcheck-YYYYMMDD-HHMMSS.log`
|
||||
|
||||
Logs include:
|
||||
- Timestamp and system information
|
||||
- Detailed check results
|
||||
- Summary statistics
|
||||
- Overall health status
|
||||
|
||||
## Scheduling
|
||||
|
||||
### Manual Execution
|
||||
```bash
|
||||
# Run on demand
|
||||
sudo calypso-healthcheck
|
||||
```
|
||||
|
||||
### Cron Job (Recommended)
|
||||
Add to crontab for automated checks:
|
||||
|
||||
```bash
|
||||
# Daily health check at 2 AM
|
||||
0 2 * * * /usr/local/bin/calypso-healthcheck > /dev/null 2>&1
|
||||
|
||||
# Weekly health check on Monday at 6 AM with email notification
|
||||
0 6 * * 1 /usr/local/bin/calypso-healthcheck 2>&1 | mail -s "Calypso Health Check" admin@example.com
|
||||
```
|
||||
|
||||
### Systemd Timer (Alternative)
|
||||
Create `/etc/systemd/system/calypso-healthcheck.timer`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Daily Calypso Health Check
|
||||
Requires=calypso-healthcheck.service
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
Create `/etc/systemd/system/calypso-healthcheck.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Calypso Appliance Health Check
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/calypso-healthcheck
|
||||
```
|
||||
|
||||
Enable:
|
||||
```bash
|
||||
systemctl enable --now calypso-healthcheck.timer
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Failures
|
||||
|
||||
#### API/Frontend Health Endpoints Failing
|
||||
```bash
|
||||
# Check if services are running
|
||||
systemctl status calypso-api calypso-frontend
|
||||
|
||||
# Check service logs
|
||||
journalctl -u calypso-api -n 50
|
||||
journalctl -u calypso-frontend -n 50
|
||||
|
||||
# Test manually
|
||||
curl -k https://localhost:8443/health
|
||||
curl -k https://localhost:3000/health
|
||||
```
|
||||
|
||||
#### Bacula Director Not Responding
|
||||
```bash
|
||||
# Check service
|
||||
systemctl status bacula-director
|
||||
|
||||
# Test bconsole
|
||||
echo "status director" | bconsole
|
||||
|
||||
# Check logs
|
||||
tail -50 /var/log/bacula/bacula.log
|
||||
```
|
||||
|
||||
#### VTL Slots Not Detected
|
||||
```bash
|
||||
# Check VTL services
|
||||
systemctl status mhvtl.target
|
||||
|
||||
# Check devices
|
||||
lsscsi | grep -E "mediumx|tape"
|
||||
|
||||
# Test manually
|
||||
mtx -f /dev/sg7 status
|
||||
echo "update slots storage=Scalar-i500" | bconsole
|
||||
```
|
||||
|
||||
#### Storage Protocols Port Not Listening
|
||||
```bash
|
||||
# Check service status
|
||||
systemctl status nfs-server smbd nmbd scst iscsi-scstd
|
||||
|
||||
# Check listening ports
|
||||
ss -tuln | grep -E "2049|445|139|3260"
|
||||
|
||||
# Restart services if needed
|
||||
systemctl restart nfs-server
|
||||
systemctl restart smbd nmbd
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
### Modify Thresholds
|
||||
Edit `/usr/local/bin/calypso-healthcheck`:
|
||||
|
||||
```bash
|
||||
# Disk usage threshold (default: 80%)
|
||||
check_disk "/" 80 "Root filesystem"
|
||||
|
||||
# Memory usage threshold (default: 90%)
|
||||
if [ "$mem_percent" -lt 90 ]; then
|
||||
|
||||
# Change expected VTL devices
|
||||
if [ "$changer_count" -ge 2 ] && [ "$tape_count" -ge 8 ]; then
|
||||
```
|
||||
|
||||
### Add Custom Checks
|
||||
Add new check functions:
|
||||
|
||||
```bash
|
||||
check_custom() {
|
||||
TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
|
||||
|
||||
if [[ condition ]]; then
|
||||
echo -e "${GREEN}${CHECK}${NC} Custom check passed" | tee -a "$LOG_FILE"
|
||||
PASSED_CHECKS=$((PASSED_CHECKS + 1))
|
||||
else
|
||||
echo -e "${RED}${CROSS}${NC} Custom check failed" | tee -a "$LOG_FILE"
|
||||
FAILED_CHECKS=$((FAILED_CHECKS + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Call in main script
|
||||
check_custom
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
### Monitoring Systems
|
||||
Export metrics for monitoring:
|
||||
|
||||
```bash
|
||||
# Nagios/Icinga format
|
||||
calypso-healthcheck
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "OK - All checks passed"
|
||||
exit 0
|
||||
elif [ $? -eq 1 ]; then
|
||||
echo "WARNING - Healthy with warnings"
|
||||
exit 1
|
||||
else
|
||||
echo "CRITICAL - System degraded"
|
||||
exit 2
|
||||
fi
|
||||
```
|
||||
|
||||
### API Integration
|
||||
Parse JSON output:
|
||||
|
||||
```bash
|
||||
# Add JSON output option
|
||||
calypso-healthcheck --json > /tmp/health.json
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Log Rotation
|
||||
Logs are stored in `/var/log/calypso-healthcheck-*.log`
|
||||
|
||||
Create `/etc/logrotate.d/calypso-healthcheck`:
|
||||
```
|
||||
/var/log/calypso-healthcheck-*.log {
|
||||
weekly
|
||||
rotate 12
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
}
|
||||
```
|
||||
|
||||
### Cleanup Old Logs
|
||||
```bash
|
||||
# Remove logs older than 30 days
|
||||
find /var/log -name "calypso-healthcheck-*.log" -mtime +30 -delete
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Run after reboot** - Verify all services started correctly
|
||||
2. **Schedule regular checks** - Daily or weekly automated runs
|
||||
3. **Monitor exit codes** - Alert on degraded/critical status
|
||||
4. **Review logs periodically** - Identify patterns or recurring issues
|
||||
5. **Update checks** - Add new components as system evolves
|
||||
6. **Baseline health** - Establish normal operating parameters
|
||||
7. **Document exceptions** - Note known warnings that are acceptable
|
||||
|
||||
## See Also
|
||||
- `pre-reboot-checklist.md` - Pre-reboot verification
|
||||
- `bacula-vtl-troubleshooting.md` - VTL troubleshooting guide
|
||||
- System logs: `/var/log/syslog`, `/var/log/bacula/`
|
||||
|
||||
---
|
||||
|
||||
*Created: 2025-12-31*
|
||||
*Script: `/usr/local/bin/calypso-healthcheck`*
|
||||
225
docs/pre-reboot-checklist.md
Normal file
225
docs/pre-reboot-checklist.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Calypso Appliance - Pre-Reboot Checklist
|
||||
|
||||
**Date:** 2025-12-31
|
||||
**Status:** ✅ READY FOR REBOOT
|
||||
|
||||
---
|
||||
|
||||
## Enabled Services (Auto-start on boot)
|
||||
|
||||
### Core Application Services
|
||||
| Service | Status | Purpose |
|
||||
|---------|--------|---------|
|
||||
| postgresql.service | ✅ enabled | Database backend |
|
||||
| calypso-api.service | ✅ enabled | REST API backend |
|
||||
| calypso-frontend.service | ✅ enabled | Web UI (React) |
|
||||
| calypso-logger.service | ✅ enabled | Application logging |
|
||||
|
||||
### Backup Services (Bacula)
|
||||
| Service | Status | Purpose |
|
||||
|---------|--------|---------|
|
||||
| bacula-director.service | ✅ enabled | Backup orchestration |
|
||||
| bacula-fd.service | ✅ enabled | File daemon (client) |
|
||||
| bacula-sd.service | ✅ enabled | Storage daemon (VTL) |
|
||||
|
||||
### Virtual Tape Library (mhVTL)
|
||||
| Service | Status | Purpose |
|
||||
|---------|--------|---------|
|
||||
| mhvtl.target | ✅ enabled | VTL master target |
|
||||
| vtllibrary@10.service | ✅ enabled | Scalar i500 library |
|
||||
| vtllibrary@30.service | ✅ enabled | Scalar i40 library |
|
||||
| vtltape@11-14.service | ✅ enabled | i500 tape drives (4) |
|
||||
| vtltape@31-34.service | ✅ enabled | i40 tape drives (4) |
|
||||
|
||||
### Storage Protocols
|
||||
| Service | Status | Purpose |
|
||||
|---------|--------|---------|
|
||||
| nfs-server.service | ✅ enabled | NFS file sharing |
|
||||
| nfs-blkmap.service | ✅ enabled | NFS block mapping |
|
||||
| smbd.service | ✅ enabled | Samba/CIFS server |
|
||||
| nmbd.service | ✅ enabled | NetBIOS name service |
|
||||
| scst.service | ✅ enabled | SCSI target subsystem |
|
||||
| iscsi-scstd.service | ✅ enabled | iSCSI target daemon |
|
||||
|
||||
### Monitoring & Management
|
||||
| Service | Status | Purpose |
|
||||
|---------|--------|---------|
|
||||
| snmpd.service | ✅ enabled | SNMP monitoring |
|
||||
|
||||
---
|
||||
|
||||
## Boot Order & Dependencies
|
||||
|
||||
```
|
||||
1. Network (systemd-networkd)
|
||||
↓
|
||||
2. Storage Foundation
|
||||
- NFS server
|
||||
- Samba (smbd/nmbd)
|
||||
- SCST/iSCSI
|
||||
↓
|
||||
3. PostgreSQL Database
|
||||
↓
|
||||
4. VTL Services (mhvtl.target)
|
||||
- vtllibrary services
|
||||
- vtltape services
|
||||
↓
|
||||
5. Bacula Services
|
||||
- bacula-director (after postgresql)
|
||||
- bacula-fd
|
||||
- bacula-sd (after VTL)
|
||||
↓
|
||||
6. Calypso Application
|
||||
- calypso-api (after postgresql)
|
||||
- calypso-frontend (wants calypso-api)
|
||||
- calypso-logger (wants api & frontend)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Post-Reboot Verification
|
||||
|
||||
### 1. Check System Boot
|
||||
```bash
|
||||
# Check boot time
|
||||
systemd-analyze
|
||||
systemd-analyze blame | head -20
|
||||
```
|
||||
|
||||
### 2. Check Core Services
|
||||
```bash
|
||||
# Calypso application
|
||||
systemctl status calypso-api calypso-frontend calypso-logger
|
||||
|
||||
# Database
|
||||
systemctl status postgresql
|
||||
|
||||
# Check API health
|
||||
curl -k https://localhost:8443/health
|
||||
curl -k https://localhost:3000/health
|
||||
```
|
||||
|
||||
### 3. Check Backup Services
|
||||
```bash
|
||||
# Bacula status
|
||||
systemctl status bacula-director bacula-fd bacula-sd
|
||||
|
||||
# Test bconsole connection
|
||||
echo "status director" | bconsole
|
||||
|
||||
# Check VTL connection
|
||||
echo "status storage=Scalar-i500" | bconsole
|
||||
```
|
||||
|
||||
### 4. Check Storage Protocols
|
||||
```bash
|
||||
# NFS
|
||||
systemctl status nfs-server
|
||||
showmount -e localhost
|
||||
|
||||
# Samba
|
||||
systemctl status smbd nmbd
|
||||
smbstatus
|
||||
|
||||
# iSCSI/SCST
|
||||
systemctl status scst iscsi-scstd
|
||||
scstadmin -list_target
|
||||
```
|
||||
|
||||
### 5. Check VTL Devices
|
||||
```bash
|
||||
# VTL services
|
||||
systemctl status mhvtl.target
|
||||
|
||||
# Check devices
|
||||
lsscsi | grep -E "mediumx|tape"
|
||||
|
||||
# Test autochanger
|
||||
mtx -f /dev/sg7 status | head -10
|
||||
```
|
||||
|
||||
### 6. Check Monitoring
|
||||
```bash
|
||||
# SNMP
|
||||
systemctl status snmpd
|
||||
snmpwalk -v2c -c public localhost system
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Network Access Points
|
||||
|
||||
| Service | URL/Port | Description |
|
||||
|---------|----------|-------------|
|
||||
| Web UI | https://[IP]:3000 | Calypso frontend |
|
||||
| API | https://[IP]:8443 | REST API |
|
||||
| Bacula Director | localhost:9101 | bconsole access |
|
||||
| PostgreSQL | localhost:5432 | Database |
|
||||
| NFS | tcp/2049 | NFS shares |
|
||||
| Samba | tcp/445, tcp/139 | CIFS/SMB shares |
|
||||
| iSCSI | tcp/3260 | iSCSI targets |
|
||||
| SNMP | udp/161 | Monitoring |
|
||||
|
||||
---
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Bacula VTL Configuration
|
||||
- **Scalar i500**: 43 slots, 20 tapes labeled (E01001L8-E01020L8) ✅
|
||||
- **Scalar i40**: 44 slots, needs investigation after reboot ⚠️
|
||||
- Changer devices: /dev/sg7 (i500), /dev/sg8 (i40)
|
||||
- Tape devices: /dev/nst0-7 (non-rewinding)
|
||||
- User permissions: bacula in tape+cdrom groups
|
||||
|
||||
### Storage Paths
|
||||
- Calypso working directory: `/development/calypso`
|
||||
- Bacula configs: `/etc/bacula/`
|
||||
- VTL configs: `/etc/mhvtl/`
|
||||
- PostgreSQL data: `/var/lib/postgresql/`
|
||||
|
||||
### Known Issues
|
||||
- Scalar i40 VTL: Hardware error during tape load (requires investigation)
|
||||
|
||||
---
|
||||
|
||||
## Emergency Recovery
|
||||
|
||||
If services fail to start after reboot:
|
||||
|
||||
```bash
|
||||
# Check failed services
|
||||
systemctl --failed
|
||||
|
||||
# View service logs
|
||||
journalctl -xeu calypso-api
|
||||
journalctl -xeu bacula-director
|
||||
journalctl -xeu mhvtl.target
|
||||
|
||||
# Manual service restart
|
||||
systemctl restart calypso-api
|
||||
systemctl restart bacula-sd
|
||||
systemctl restart mhvtl.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist Summary
|
||||
|
||||
- [x] PostgreSQL database: enabled
|
||||
- [x] Calypso services (api, frontend, logger): enabled
|
||||
- [x] Bacula services (director, fd, sd): enabled
|
||||
- [x] mhVTL services (libraries, tape drives): enabled
|
||||
- [x] NFS server: enabled
|
||||
- [x] Samba (smbd, nmbd): enabled
|
||||
- [x] SCST/iSCSI: enabled
|
||||
- [x] SNMP monitoring: enabled
|
||||
- [x] Network services: configured
|
||||
- [x] User permissions: configured
|
||||
- [x] Service dependencies: verified
|
||||
|
||||
**Status: SAFE TO REBOOT** ✅
|
||||
|
||||
---
|
||||
|
||||
*Generated: 2025-12-31*
|
||||
*Documentation: /development/calypso/docs/*
|
||||
@@ -46,6 +46,45 @@ export interface CreateJobRequest {
|
||||
pool_name?: string
|
||||
}
|
||||
|
||||
export interface BackupClient {
|
||||
client_id: number
|
||||
name: string
|
||||
uname?: string
|
||||
enabled: boolean
|
||||
auto_prune?: boolean
|
||||
file_retention?: number
|
||||
job_retention?: number
|
||||
last_backup_at?: string
|
||||
total_jobs?: number
|
||||
total_bytes?: number
|
||||
status?: 'online' | 'offline'
|
||||
}
|
||||
|
||||
export interface ListClientsResponse {
|
||||
clients: BackupClient[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ListClientsParams {
|
||||
enabled?: boolean
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface PoolStats {
|
||||
name: string
|
||||
used_bytes: number
|
||||
total_bytes: number
|
||||
usage_percent: number
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
director_status: string
|
||||
director_uptime: string
|
||||
last_job?: BackupJob
|
||||
active_jobs_count: number
|
||||
default_pool?: PoolStats
|
||||
}
|
||||
|
||||
export const backupAPI = {
|
||||
listJobs: async (params?: ListJobsParams): Promise<ListJobsResponse> => {
|
||||
const queryParams = new URLSearchParams()
|
||||
@@ -71,5 +110,148 @@ export const backupAPI = {
|
||||
const response = await apiClient.post<BackupJob>('/backup/jobs', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
executeBconsoleCommand: async (command: string): Promise<{ output: string }> => {
|
||||
const response = await apiClient.post<{ output: string }>('/backup/console/execute', { command })
|
||||
return response.data
|
||||
},
|
||||
|
||||
listClients: async (params?: ListClientsParams): Promise<ListClientsResponse> => {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params?.enabled !== undefined) queryParams.append('enabled', params.enabled.toString())
|
||||
if (params?.search) queryParams.append('search', params.search)
|
||||
|
||||
const response = await apiClient.get<ListClientsResponse>(
|
||||
`/backup/clients${queryParams.toString() ? `?${queryParams.toString()}` : ''}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getDashboardStats: async (): Promise<DashboardStats> => {
|
||||
const response = await apiClient.get<DashboardStats>('/backup/dashboard/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
listStoragePools: async (): Promise<{ pools: StoragePool[]; total: number }> => {
|
||||
const response = await apiClient.get<{ pools: StoragePool[]; total: number }>('/backup/storage/pools')
|
||||
return response.data
|
||||
},
|
||||
|
||||
listStorageVolumes: async (poolName?: string): Promise<{ volumes: StorageVolume[]; total: number }> => {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (poolName) queryParams.append('pool_name', poolName)
|
||||
const response = await apiClient.get<{ volumes: StorageVolume[]; total: number }>(
|
||||
`/backup/storage/volumes${queryParams.toString() ? `?${queryParams.toString()}` : ''}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
listStorageDaemons: async (): Promise<{ daemons: StorageDaemon[]; total: number }> => {
|
||||
const response = await apiClient.get<{ daemons: StorageDaemon[]; total: number }>('/backup/storage/daemons')
|
||||
return response.data
|
||||
},
|
||||
|
||||
createStoragePool: async (data: CreateStoragePoolRequest): Promise<StoragePool> => {
|
||||
const response = await apiClient.post<StoragePool>('/backup/storage/pools', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteStoragePool: async (poolId: number): Promise<void> => {
|
||||
await apiClient.delete(`/backup/storage/pools/${poolId}`)
|
||||
},
|
||||
|
||||
createStorageVolume: async (data: CreateStorageVolumeRequest): Promise<StorageVolume> => {
|
||||
const response = await apiClient.post<StorageVolume>('/backup/storage/volumes', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateStorageVolume: async (volumeId: number, data: UpdateStorageVolumeRequest): Promise<StorageVolume> => {
|
||||
const response = await apiClient.put<StorageVolume>(`/backup/storage/volumes/${volumeId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteStorageVolume: async (volumeId: number): Promise<void> => {
|
||||
await apiClient.delete(`/backup/storage/volumes/${volumeId}`)
|
||||
},
|
||||
|
||||
listMedia: async (): Promise<{ media: Media[]; total: number }> => {
|
||||
const response = await apiClient.get<{ media: Media[]; total: number }>('/backup/media')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export interface CreateStoragePoolRequest {
|
||||
name: string
|
||||
pool_type?: string
|
||||
label_format?: string
|
||||
recycle?: boolean
|
||||
auto_prune?: boolean
|
||||
}
|
||||
|
||||
export interface CreateStorageVolumeRequest {
|
||||
volume_name: string
|
||||
pool_name: string
|
||||
media_type?: string
|
||||
max_vol_bytes?: number
|
||||
vol_retention?: number
|
||||
}
|
||||
|
||||
export interface UpdateStorageVolumeRequest {
|
||||
max_vol_bytes?: number
|
||||
vol_retention?: number
|
||||
}
|
||||
|
||||
export interface Media {
|
||||
media_id: number
|
||||
volume_name: string
|
||||
pool_name: string
|
||||
media_type: string
|
||||
status: string
|
||||
vol_bytes: number
|
||||
max_vol_bytes: number
|
||||
vol_files: number
|
||||
last_written?: string
|
||||
recycle_count: number
|
||||
slot?: number
|
||||
in_changer?: number
|
||||
library_name?: string
|
||||
}
|
||||
|
||||
export interface StoragePool {
|
||||
pool_id: number
|
||||
name: string
|
||||
pool_type: string
|
||||
label_format?: string
|
||||
recycle?: boolean
|
||||
auto_prune?: boolean
|
||||
volume_count: number
|
||||
used_bytes: number
|
||||
total_bytes: number
|
||||
usage_percent: number
|
||||
}
|
||||
|
||||
export interface StorageVolume {
|
||||
volume_id: number
|
||||
media_id: number
|
||||
volume_name: string
|
||||
pool_name: string
|
||||
media_type: string
|
||||
vol_status: string
|
||||
vol_bytes: number
|
||||
max_vol_bytes: number
|
||||
vol_files: number
|
||||
vol_retention?: string
|
||||
last_written?: string
|
||||
recycle_count: number
|
||||
}
|
||||
|
||||
export interface StorageDaemon {
|
||||
storage_id: number
|
||||
name: string
|
||||
address: string
|
||||
port: number
|
||||
device_name: string
|
||||
media_type: string
|
||||
status: string
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ const apiClient = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -88,7 +88,14 @@ export interface AddInitiatorRequest {
|
||||
|
||||
export const scstAPI = {
|
||||
listTargets: async (): Promise<SCSTTarget[]> => {
|
||||
const response = await apiClient.get('/scst/targets')
|
||||
const response = await apiClient.get('/scst/targets', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
params: {
|
||||
_t: Date.now(), // Add timestamp to prevent browser caching
|
||||
},
|
||||
})
|
||||
return response.data.targets || []
|
||||
},
|
||||
|
||||
@@ -97,7 +104,14 @@ export const scstAPI = {
|
||||
luns: SCSTLUN[]
|
||||
initiator_groups?: SCSTInitiatorGroup[]
|
||||
}> => {
|
||||
const response = await apiClient.get(`/scst/targets/${id}`)
|
||||
const response = await apiClient.get(`/scst/targets/${id}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
params: {
|
||||
_t: Date.now(), // Add timestamp to prevent browser caching
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -112,6 +126,11 @@ export const scstAPI = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
removeLUN: async (targetId: string, lunId: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete(`/scst/targets/${targetId}/luns/${lunId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
addInitiator: async (targetId: string, data: AddInitiatorRequest): Promise<{ task_id: string }> => {
|
||||
const response = await apiClient.post(`/scst/targets/${targetId}/initiators`, data)
|
||||
return response.data
|
||||
@@ -123,17 +142,38 @@ export const scstAPI = {
|
||||
},
|
||||
|
||||
listHandlers: async (): Promise<SCSTHandler[]> => {
|
||||
const response = await apiClient.get('/scst/handlers')
|
||||
const response = await apiClient.get('/scst/handlers', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
params: {
|
||||
_t: Date.now(),
|
||||
},
|
||||
})
|
||||
return response.data.handlers || []
|
||||
},
|
||||
|
||||
listPortals: async (): Promise<SCSTPortal[]> => {
|
||||
const response = await apiClient.get('/scst/portals')
|
||||
const response = await apiClient.get('/scst/portals', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
params: {
|
||||
_t: Date.now(), // Add timestamp to prevent browser caching
|
||||
},
|
||||
})
|
||||
return response.data.portals || []
|
||||
},
|
||||
|
||||
getPortal: async (id: string): Promise<SCSTPortal> => {
|
||||
const response = await apiClient.get(`/scst/portals/${id}`)
|
||||
const response = await apiClient.get(`/scst/portals/${id}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
params: {
|
||||
_t: Date.now(),
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -161,13 +201,32 @@ export const scstAPI = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteTarget: async (targetId: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete(`/scst/targets/${targetId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
listInitiators: async (): Promise<SCSTInitiator[]> => {
|
||||
const response = await apiClient.get('/scst/initiators')
|
||||
const response = await apiClient.get('/scst/initiators', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
params: {
|
||||
_t: Date.now(),
|
||||
},
|
||||
})
|
||||
return response.data.initiators || []
|
||||
},
|
||||
|
||||
getInitiator: async (id: string): Promise<SCSTInitiator> => {
|
||||
const response = await apiClient.get(`/scst/initiators/${id}`)
|
||||
const response = await apiClient.get(`/scst/initiators/${id}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
params: {
|
||||
_t: Date.now(),
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -176,7 +235,14 @@ export const scstAPI = {
|
||||
},
|
||||
|
||||
listExtents: async (): Promise<SCSTExtent[]> => {
|
||||
const response = await apiClient.get('/scst/extents')
|
||||
const response = await apiClient.get('/scst/extents', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
params: {
|
||||
_t: Date.now(), // Add timestamp to prevent browser caching
|
||||
},
|
||||
})
|
||||
return response.data.extents || []
|
||||
},
|
||||
|
||||
@@ -188,6 +254,52 @@ export const scstAPI = {
|
||||
deleteExtent: async (deviceName: string): Promise<void> => {
|
||||
await apiClient.delete(`/scst/extents/${deviceName}`)
|
||||
},
|
||||
|
||||
// Initiator Groups
|
||||
listInitiatorGroups: async (): Promise<SCSTInitiatorGroup[]> => {
|
||||
const response = await apiClient.get('/scst/initiator-groups', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
params: {
|
||||
_t: Date.now(), // Add timestamp to prevent browser caching
|
||||
},
|
||||
})
|
||||
return response.data.groups || []
|
||||
},
|
||||
|
||||
getInitiatorGroup: async (id: string): Promise<SCSTInitiatorGroup> => {
|
||||
const response = await apiClient.get(`/scst/initiator-groups/${id}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
params: {
|
||||
_t: Date.now(),
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
createInitiatorGroup: async (data: { target_id: string; group_name: string }): Promise<SCSTInitiatorGroup> => {
|
||||
const response = await apiClient.post('/scst/initiator-groups', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateInitiatorGroup: async (id: string, data: { group_name: string }): Promise<SCSTInitiatorGroup> => {
|
||||
const response = await apiClient.put(`/scst/initiator-groups/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteInitiatorGroup: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/scst/initiator-groups/${id}`)
|
||||
},
|
||||
|
||||
addInitiatorToGroup: async (groupId: string, initiatorIQN: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post(`/scst/initiator-groups/${groupId}/initiators`, {
|
||||
initiator_iqn: initiatorIQN,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export interface SCSTExtent {
|
||||
|
||||
@@ -7,6 +7,50 @@ export interface NetworkInterface {
|
||||
status: string // "Connected" or "Down"
|
||||
speed: string // e.g., "10 Gbps", "1 Gbps"
|
||||
role: string // "Management", "ISCSI", or empty
|
||||
gateway?: string
|
||||
dns1?: string
|
||||
dns2?: string
|
||||
}
|
||||
|
||||
export interface UpdateNetworkInterfaceRequest {
|
||||
ip_address: string
|
||||
subnet: string
|
||||
gateway?: string
|
||||
dns1?: string
|
||||
dns2?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
export interface SaveNTPSettingsRequest {
|
||||
timezone: string
|
||||
ntp_servers: string[]
|
||||
}
|
||||
|
||||
export interface NTPSettings {
|
||||
timezone: string
|
||||
ntp_servers: string[]
|
||||
}
|
||||
|
||||
export interface ServiceStatus {
|
||||
name: string
|
||||
active_state: string // "active", "inactive", "activating", "deactivating", "failed"
|
||||
sub_state: string
|
||||
load_state: string
|
||||
description: string
|
||||
since?: string
|
||||
}
|
||||
|
||||
export interface SystemLogEntry {
|
||||
time: string
|
||||
level: string
|
||||
source: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface NetworkDataPoint {
|
||||
time: string
|
||||
inbound: number // Mbps
|
||||
outbound: number // Mbps
|
||||
}
|
||||
|
||||
export const systemAPI = {
|
||||
@@ -14,5 +58,31 @@ export const systemAPI = {
|
||||
const response = await apiClient.get<{ interfaces: NetworkInterface[] | null }>('/system/interfaces')
|
||||
return response.data.interfaces || []
|
||||
},
|
||||
updateNetworkInterface: async (name: string, data: UpdateNetworkInterfaceRequest): Promise<NetworkInterface> => {
|
||||
const response = await apiClient.put<{ interface: NetworkInterface }>(`/system/interfaces/${name}`, data)
|
||||
return response.data.interface
|
||||
},
|
||||
getNTPSettings: async (): Promise<NTPSettings> => {
|
||||
const response = await apiClient.get<{ settings: NTPSettings }>('/system/ntp')
|
||||
return response.data.settings
|
||||
},
|
||||
saveNTPSettings: async (data: SaveNTPSettingsRequest): Promise<void> => {
|
||||
await apiClient.post('/system/ntp', data)
|
||||
},
|
||||
listServices: async (): Promise<ServiceStatus[]> => {
|
||||
const response = await apiClient.get<{ services: ServiceStatus[] }>('/system/services')
|
||||
return response.data.services || []
|
||||
},
|
||||
restartService: async (name: string): Promise<void> => {
|
||||
await apiClient.post(`/system/services/${name}/restart`)
|
||||
},
|
||||
getSystemLogs: async (limit: number = 30): Promise<SystemLogEntry[]> => {
|
||||
const response = await apiClient.get<{ logs: SystemLogEntry[] }>(`/system/logs?limit=${limit}`)
|
||||
return response.data.logs || []
|
||||
},
|
||||
getNetworkThroughput: async (duration: string = '5m'): Promise<NetworkDataPoint[]> => {
|
||||
const response = await apiClient.get<{ data: NetworkDataPoint[] }>(`/system/network/throughput?duration=${duration}`)
|
||||
return response.data.data || []
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { useState, useMemo, useEffect } from 'react'
|
||||
import apiClient from '@/api/client'
|
||||
import { monitoringApi } from '@/api/monitoring'
|
||||
import { storageApi } from '@/api/storage'
|
||||
import { systemAPI } from '@/api/system'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import {
|
||||
Cpu,
|
||||
@@ -46,17 +47,18 @@ const MOCK_ACTIVE_JOBS = [
|
||||
},
|
||||
]
|
||||
|
||||
const MOCK_SYSTEM_LOGS = [
|
||||
{ time: '10:45:22', level: 'INFO', source: 'systemd', message: 'Started User Manager for UID 1000.' },
|
||||
{ time: '10:45:15', level: 'WARN', source: 'smartd', message: 'Device: /dev/ada5, SMART Usage Attribute: 194 Temperature_Celsius changed from 38 to 41' },
|
||||
{ time: '10:44:58', level: 'INFO', source: 'kernel', message: 'ix0: link state changed to UP' },
|
||||
{ time: '10:42:10', level: 'INFO', source: 'zfs', message: 'zfs_arc_reclaim_thread: reclaiming 157286400 bytes ...' },
|
||||
]
|
||||
|
||||
export default function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState<'jobs' | 'logs' | 'alerts'>('jobs')
|
||||
const [networkDataPoints, setNetworkDataPoints] = useState<Array<{ time: string; inbound: number; outbound: number }>>([])
|
||||
const refreshInterval = 5
|
||||
|
||||
// Fetch system logs with auto-refresh every 10 minutes
|
||||
const { data: systemLogs = [], isLoading: logsLoading, refetch: refetchLogs } = useQuery({
|
||||
queryKey: ['system-logs'],
|
||||
queryFn: () => systemAPI.getSystemLogs(30),
|
||||
refetchInterval: 10 * 60 * 1000, // 10 minutes
|
||||
})
|
||||
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['health'],
|
||||
@@ -143,51 +145,25 @@ export default function Dashboard() {
|
||||
return { totalStorage: total, usedStorage: used, storagePercent: percent }
|
||||
}, [repositories])
|
||||
|
||||
// Initialize network data
|
||||
// Fetch network throughput data from RRD
|
||||
const { data: networkThroughput = [] } = useQuery({
|
||||
queryKey: ['network-throughput'],
|
||||
queryFn: () => systemAPI.getNetworkThroughput('5m'),
|
||||
refetchInterval: 5 * 1000, // Refresh every 5 seconds
|
||||
})
|
||||
|
||||
// Update network data points when new data arrives
|
||||
useEffect(() => {
|
||||
// Generate initial 30 data points
|
||||
const initialData = []
|
||||
const now = Date.now()
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const time = new Date(now - i * 5000)
|
||||
const minutes = time.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = time.getSeconds().toString().padStart(2, '0')
|
||||
|
||||
const baseInbound = 800 + Math.random() * 400
|
||||
const baseOutbound = 400 + Math.random() * 200
|
||||
|
||||
initialData.push({
|
||||
time: `${minutes}:${seconds}`,
|
||||
inbound: Math.round(baseInbound),
|
||||
outbound: Math.round(baseOutbound),
|
||||
})
|
||||
if (networkThroughput.length > 0) {
|
||||
// Take last 30 points
|
||||
const points = networkThroughput.slice(-30).map((point) => ({
|
||||
time: point.time,
|
||||
inbound: Math.round(point.inbound),
|
||||
outbound: Math.round(point.outbound),
|
||||
}))
|
||||
setNetworkDataPoints(points)
|
||||
}
|
||||
setNetworkDataPoints(initialData)
|
||||
|
||||
// Update data every 5 seconds
|
||||
const interval = setInterval(() => {
|
||||
setNetworkDataPoints((prev) => {
|
||||
const now = new Date()
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = now.getSeconds().toString().padStart(2, '0')
|
||||
|
||||
const baseInbound = 800 + Math.random() * 400
|
||||
const baseOutbound = 400 + Math.random() * 200
|
||||
|
||||
const newPoint = {
|
||||
time: `${minutes}:${seconds}`,
|
||||
inbound: Math.round(baseInbound),
|
||||
outbound: Math.round(baseOutbound),
|
||||
}
|
||||
|
||||
// Keep only last 30 points
|
||||
const updated = [...prev.slice(1), newPoint]
|
||||
return updated
|
||||
})
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
}, [networkThroughput])
|
||||
|
||||
// Calculate current and peak throughput
|
||||
const currentThroughput = useMemo(() => {
|
||||
@@ -564,39 +540,59 @@ export default function Dashboard() {
|
||||
<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 className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => refetchLogs()}
|
||||
disabled={logsLoading}
|
||||
className="text-xs text-primary hover:text-white transition-colors flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={14} className={logsLoading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
<button className="text-xs text-primary hover:text-white transition-colors">
|
||||
View All Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{MOCK_SYSTEM_LOGS.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">
|
||||
{log.time}
|
||||
</td>
|
||||
<td className="px-6 py-2 w-24">
|
||||
<span
|
||||
className={
|
||||
log.level === 'INFO'
|
||||
? 'text-emerald-500'
|
||||
: log.level === 'WARN'
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500'
|
||||
}
|
||||
>
|
||||
{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>
|
||||
{logsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-text-secondary">Loading logs...</span>
|
||||
</div>
|
||||
) : systemLogs.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-text-secondary">No logs available</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<tbody className="text-sm font-mono divide-y divide-border-dark/50">
|
||||
{systemLogs.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">
|
||||
{log.time}
|
||||
</td>
|
||||
<td className="px-6 py-2 w-24">
|
||||
<span
|
||||
className={
|
||||
log.level === 'INFO' || log.level === 'NOTICE' || log.level === 'DEBUG'
|
||||
? 'text-emerald-500'
|
||||
: log.level === 'WARN'
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500'
|
||||
}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -696,10 +696,15 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
||||
iamApi.updateUser(user.id, data),
|
||||
onSuccess: async () => {
|
||||
onSuccess()
|
||||
// Invalidate all related queries to refresh counts
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-roles'] }) // Refresh role user counts
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-groups'] }) // Refresh group user counts
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] })
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Failed to update user:', error)
|
||||
@@ -725,9 +730,11 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
||||
},
|
||||
onSuccess: async (_, roleName: string) => {
|
||||
// Don't overwrite state with server data - keep optimistic update
|
||||
// Just invalidate queries for other components
|
||||
// Invalidate queries to refresh counts
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-roles'] }) // Refresh role user count
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
|
||||
// Use functional update to get current state
|
||||
setUserRoles(current => {
|
||||
console.log('assignRoleMutation onSuccess - roleName:', roleName, 'current userRoles:', current)
|
||||
@@ -753,9 +760,11 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
||||
},
|
||||
onSuccess: async (_, roleName: string) => {
|
||||
// Don't overwrite state with server data - keep optimistic update
|
||||
// Just invalidate queries for other components
|
||||
// Invalidate queries to refresh counts
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-roles'] }) // Refresh role user count
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
|
||||
console.log('Role removed successfully:', roleName, 'Current userRoles:', userRoles)
|
||||
},
|
||||
onError: (error: any, _roleName: string, context: any) => {
|
||||
@@ -785,9 +794,11 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
||||
},
|
||||
onSuccess: async (_, groupName: string) => {
|
||||
// Don't overwrite state with server data - keep optimistic update
|
||||
// Just invalidate queries for other components
|
||||
// Invalidate queries to refresh counts
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-groups'] }) // Refresh group user count
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
|
||||
// Use functional update to get current state
|
||||
setUserGroups(current => {
|
||||
console.log('assignGroupMutation onSuccess - groupName:', groupName, 'current userGroups:', current)
|
||||
@@ -813,9 +824,11 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
||||
},
|
||||
onSuccess: async (_, groupName: string) => {
|
||||
// Don't overwrite state with server data - keep optimistic update
|
||||
// Just invalidate queries for other components
|
||||
// Invalidate queries to refresh counts
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['iam-groups'] }) // Refresh group user count
|
||||
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
|
||||
console.log('Group removed successfully:', groupName, 'Current userGroups:', userGroups)
|
||||
},
|
||||
onError: (error: any, _groupName: string, context: any) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { scstAPI, type SCSTHandler } from '@/api/scst'
|
||||
import { scstAPI, type SCSTTarget, type SCSTExtent } from '@/api/scst'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, Plus, RefreshCw, HardDrive, Users } from 'lucide-react'
|
||||
import { ArrowLeft, Plus, RefreshCw, HardDrive, Users, Trash2 } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function ISCSITargetDetail() {
|
||||
@@ -23,11 +23,64 @@ export default function ISCSITargetDetail() {
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
const { data: handlers } = useQuery<SCSTHandler[]>({
|
||||
queryKey: ['scst-handlers'],
|
||||
queryFn: scstAPI.listHandlers,
|
||||
staleTime: 0, // Always fetch fresh data
|
||||
refetchOnMount: true,
|
||||
const removeLUNMutation = useMutation({
|
||||
mutationFn: ({ targetId, lunId }: { targetId: string; lunId: string }) =>
|
||||
scstAPI.removeLUN(targetId, lunId),
|
||||
onMutate: async ({ lunId }) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['scst-target', id] })
|
||||
await queryClient.cancelQueries({ queryKey: ['scst-targets'] })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousTarget = queryClient.getQueryData(['scst-target', id])
|
||||
const previousTargets = queryClient.getQueryData<SCSTTarget[]>(['scst-targets'])
|
||||
|
||||
// Optimistically update to remove the LUN
|
||||
queryClient.setQueryData(['scst-target', id], (old: any) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
luns: old.luns ? old.luns.filter((lun: any) => lun.id !== lunId) : []
|
||||
}
|
||||
})
|
||||
|
||||
// Optimistically update LUN count in targets list
|
||||
queryClient.setQueryData<SCSTTarget[]>(['scst-targets'], (old) => {
|
||||
if (!old) return old
|
||||
return old.map(t =>
|
||||
t.id === id
|
||||
? { ...t, lun_count: Math.max(0, (t.lun_count || 0) - 1) }
|
||||
: t
|
||||
)
|
||||
})
|
||||
|
||||
return { previousTarget, previousTargets }
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate queries to refetch data from the server.
|
||||
// This is simpler and less prone to race conditions than the previous implementation.
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-target', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-targets'] });
|
||||
},
|
||||
onError: (error: any, _variables, context) => {
|
||||
// If 404, treat as success (LUN already deleted)
|
||||
if (error.response?.status === 404) {
|
||||
// LUN already deleted, just refresh to sync UI
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-target', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-targets'] });
|
||||
return
|
||||
}
|
||||
|
||||
// Rollback optimistic update
|
||||
if (context?.previousTarget) {
|
||||
queryClient.setQueryData(['scst-target', id], context.previousTarget)
|
||||
}
|
||||
if (context?.previousTargets) {
|
||||
queryClient.setQueryData<SCSTTarget[]>(['scst-targets'], context.previousTargets)
|
||||
}
|
||||
|
||||
alert(`Failed to remove LUN: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
@@ -124,7 +177,7 @@ export default function ISCSITargetDetail() {
|
||||
onClick={() => setShowAddLUN(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add LUN
|
||||
Assign Extent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -153,12 +206,11 @@ export default function ISCSITargetDetail() {
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
console.log('Add LUN button clicked, setting showAddLUN to true')
|
||||
setShowAddLUN(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add LUN
|
||||
Assign Extent
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -183,6 +235,9 @@ export default function ISCSITargetDetail() {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-text-secondary uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card-dark divide-y divide-border-dark">
|
||||
@@ -211,6 +266,21 @@ export default function ISCSITargetDetail() {
|
||||
{lun.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`Remove LUN ${lun.lun_number} from this target?`)) {
|
||||
removeLUNMutation.mutate({ targetId: target.id, lunId: lun.id })
|
||||
}
|
||||
}}
|
||||
disabled={removeLUNMutation.isPending}
|
||||
className="p-1.5 hover:bg-red-500/10 rounded text-text-secondary hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
title="Remove LUN"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -224,27 +294,28 @@ export default function ISCSITargetDetail() {
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
console.log('Add First LUN button clicked, setting showAddLUN to true')
|
||||
setShowAddLUN(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add First LUN
|
||||
Assign First Extent
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add LUN Form */}
|
||||
{/* Assign Extent Form */}
|
||||
{showAddLUN && (
|
||||
<AddLUNForm
|
||||
<AssignExtentForm
|
||||
targetId={target.id}
|
||||
handlers={handlers || []}
|
||||
onClose={() => setShowAddLUN(false)}
|
||||
onSuccess={() => {
|
||||
onSuccess={async () => {
|
||||
setShowAddLUN(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-target', id] })
|
||||
// Invalidate queries to refetch data.
|
||||
// Invalidate extents since one is now in use.
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-target', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-extents'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -264,47 +335,56 @@ export default function ISCSITargetDetail() {
|
||||
)
|
||||
}
|
||||
|
||||
interface AddLUNFormProps {
|
||||
interface AssignExtentFormProps {
|
||||
targetId: string
|
||||
handlers: SCSTHandler[]
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
onSuccess: () => Promise<void>
|
||||
}
|
||||
|
||||
function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps) {
|
||||
const [handlerType, setHandlerType] = useState('')
|
||||
const [devicePath, setDevicePath] = useState('')
|
||||
const [deviceName, setDeviceName] = useState('')
|
||||
function AssignExtentForm({ targetId, onClose, onSuccess }: AssignExtentFormProps) {
|
||||
const [selectedExtent, setSelectedExtent] = useState('')
|
||||
const [lunNumber, setLunNumber] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
console.log('AddLUNForm mounted, targetId:', targetId, 'handlers:', handlers)
|
||||
}, [targetId, handlers])
|
||||
// Fetch available extents
|
||||
const { data: extents = [], isLoading: extentsLoading } = useQuery<SCSTExtent[]>({
|
||||
queryKey: ['scst-extents'],
|
||||
queryFn: scstAPI.listExtents,
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
})
|
||||
|
||||
// Filter only extents that are not in use
|
||||
const availableExtents = extents.filter(extent => !extent.is_in_use)
|
||||
|
||||
const addLUNMutation = useMutation({
|
||||
mutationFn: (data: { device_name: string; device_path: string; lun_number: number; handler_type: string }) =>
|
||||
scstAPI.addLUN(targetId, data),
|
||||
onSuccess: () => {
|
||||
onSuccess()
|
||||
onSuccess: async () => {
|
||||
await onSuccess()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Failed to add LUN:', error)
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Failed to add LUN'
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Failed to assign extent'
|
||||
alert(errorMessage)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!handlerType || !devicePath || !deviceName || lunNumber < 0) {
|
||||
alert('All fields are required')
|
||||
if (!selectedExtent || lunNumber < 0) {
|
||||
alert('Please select an extent and specify LUN number')
|
||||
return
|
||||
}
|
||||
|
||||
const extent = availableExtents.find(e => e.device_name === selectedExtent)
|
||||
if (!extent) {
|
||||
alert('Selected extent not found')
|
||||
return
|
||||
}
|
||||
|
||||
addLUNMutation.mutate({
|
||||
handler_type: handlerType.trim(),
|
||||
device_path: devicePath.trim(),
|
||||
device_name: deviceName.trim(),
|
||||
device_name: extent.device_name,
|
||||
device_path: extent.device_path,
|
||||
handler_type: extent.handler_type,
|
||||
lun_number: lunNumber,
|
||||
})
|
||||
}
|
||||
@@ -313,74 +393,68 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-border-dark">
|
||||
<h2 className="text-xl font-bold text-white">Add LUN</h2>
|
||||
<p className="text-sm text-text-secondary mt-1">Bind a ZFS volume or storage device to this target</p>
|
||||
<h2 className="text-xl font-bold text-white">Assign Extent</h2>
|
||||
<p className="text-sm text-text-secondary mt-1">Assign an existing extent to this target as a LUN</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="handlerType" className="block text-sm font-medium text-white mb-1">
|
||||
Handler Type *
|
||||
<label htmlFor="extent" className="block text-sm font-medium text-white mb-1">
|
||||
Available Extent *
|
||||
</label>
|
||||
<select
|
||||
id="handlerType"
|
||||
value={handlerType}
|
||||
onChange={(e) => setHandlerType(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
>
|
||||
<option value="">Select a handler</option>
|
||||
{handlers.map((h) => (
|
||||
<option key={h.name} value={h.name}>
|
||||
{h.label || h.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="devicePath" className="block text-sm font-medium text-white mb-1">
|
||||
ZFS Volume Path *
|
||||
</label>
|
||||
<input
|
||||
id="devicePath"
|
||||
type="text"
|
||||
value={devicePath}
|
||||
onChange={(e) => {
|
||||
const path = e.target.value.trim()
|
||||
setDevicePath(path)
|
||||
// Auto-generate device name from path (e.g., /dev/zvol/pool/volume -> volume)
|
||||
if (path && !deviceName) {
|
||||
const parts = path.split('/')
|
||||
const name = parts[parts.length - 1] || parts[parts.length - 2] || 'device'
|
||||
setDeviceName(name)
|
||||
}
|
||||
}}
|
||||
placeholder="/dev/zvol/pool/volume or /dev/sda"
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary font-mono"
|
||||
required
|
||||
/>
|
||||
{extentsLoading ? (
|
||||
<div className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-text-secondary text-sm">
|
||||
Loading extents...
|
||||
</div>
|
||||
) : availableExtents.length === 0 ? (
|
||||
<div className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-text-secondary text-sm">
|
||||
No available extents. Please create an extent first in the Extents tab.
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="extent"
|
||||
value={selectedExtent}
|
||||
onChange={(e) => setSelectedExtent(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
>
|
||||
<option value="">Select an extent...</option>
|
||||
{availableExtents.map((extent) => (
|
||||
<option key={extent.device_name} value={extent.device_name}>
|
||||
{extent.device_name} ({extent.handler_type}) - {extent.device_path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Enter ZFS volume path (e.g., /dev/zvol/pool/volume) or block device path
|
||||
Select an extent that has been created in the Extents tab
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="deviceName" className="block text-sm font-medium text-white mb-1">
|
||||
Device Name *
|
||||
</label>
|
||||
<input
|
||||
id="deviceName"
|
||||
type="text"
|
||||
value={deviceName}
|
||||
onChange={(e) => setDeviceName(e.target.value)}
|
||||
placeholder="device1"
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Logical name for this device in SCST (auto-filled from volume path)
|
||||
</p>
|
||||
</div>
|
||||
{selectedExtent && (
|
||||
<div className="p-4 bg-[#0f161d] border border-border-dark rounded-lg">
|
||||
<p className="text-sm text-text-secondary mb-2">Extent Details:</p>
|
||||
{(() => {
|
||||
const extent = availableExtents.find(e => e.device_name === selectedExtent)
|
||||
if (!extent) return null
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">Device Name:</span>
|
||||
<span className="text-white font-mono">{extent.device_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">Handler:</span>
|
||||
<span className="text-white">{extent.handler_type}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">Path:</span>
|
||||
<span className="text-white font-mono text-xs">{extent.device_path}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="lunNumber" className="block text-sm font-medium text-white mb-1">
|
||||
@@ -392,6 +466,7 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
||||
value={lunNumber}
|
||||
onChange={(e) => setLunNumber(parseInt(e.target.value) || 0)}
|
||||
min="0"
|
||||
max="255"
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
@@ -404,8 +479,11 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={addLUNMutation.isPending}>
|
||||
{addLUNMutation.isPending ? 'Adding...' : 'Add LUN'}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={addLUNMutation.isPending || availableExtents.length === 0}
|
||||
>
|
||||
{addLUNMutation.isPending ? 'Assigning...' : 'Assign Extent'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -15,6 +15,14 @@ export default function ISCSITargets() {
|
||||
const { data: targets, isLoading } = useQuery<SCSTTarget[]>({
|
||||
queryKey: ['scst-targets'],
|
||||
queryFn: scstAPI.listTargets,
|
||||
refetchInterval: 3000, // Auto-refresh every 3 seconds
|
||||
refetchIntervalInBackground: true, // Continue refetching even when tab is in background
|
||||
refetchOnWindowFocus: true, // Refetch when window regains focus
|
||||
refetchOnMount: true, // Always refetch on mount
|
||||
refetchOnReconnect: true, // Refetch when network reconnects
|
||||
staleTime: 0, // Consider data stale immediately to ensure fresh data
|
||||
gcTime: 0, // Don't cache data (formerly cacheTime)
|
||||
structuralSharing: false, // Disable structural sharing to ensure updates are detected
|
||||
})
|
||||
|
||||
const applyConfigMutation = useMutation({
|
||||
@@ -158,6 +166,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('groups')}
|
||||
className={`relative py-4 text-sm tracking-wide transition-colors ${
|
||||
activeTab === 'groups'
|
||||
? 'text-primary font-bold'
|
||||
: 'text-text-secondary hover:text-white font-medium'
|
||||
}`}
|
||||
>
|
||||
Groups
|
||||
{activeTab === 'groups' && (
|
||||
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,6 +227,12 @@ export default function ISCSITargets() {
|
||||
target={target}
|
||||
isExpanded={expandedTarget === target.id}
|
||||
onToggle={() => setExpandedTarget(expandedTarget === target.id ? null : target.id)}
|
||||
onDelete={() => {
|
||||
// Close expanded view if this target was expanded
|
||||
if (expandedTarget === target.id) {
|
||||
setExpandedTarget(null)
|
||||
}
|
||||
}}
|
||||
isLast={index === filteredTargets.length - 1}
|
||||
/>
|
||||
))}
|
||||
@@ -244,6 +271,10 @@ export default function ISCSITargets() {
|
||||
{activeTab === 'extents' && (
|
||||
<ExtentsTab />
|
||||
)}
|
||||
|
||||
{activeTab === 'groups' && (
|
||||
<InitiatorGroupsTab />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -253,7 +284,7 @@ export default function ISCSITargets() {
|
||||
onClose={() => setShowCreateForm(false)}
|
||||
onSuccess={async () => {
|
||||
setShowCreateForm(false)
|
||||
// Invalidate and refetch to ensure fresh data
|
||||
// Force refetch targets list to ensure fresh data
|
||||
await queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['scst-targets'] })
|
||||
}}
|
||||
@@ -267,10 +298,11 @@ interface TargetRowProps {
|
||||
target: SCSTTarget
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onDelete?: () => void
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) {
|
||||
function TargetRow({ target, isExpanded, onToggle, onDelete }: TargetRowProps) {
|
||||
// Fetch LUNs when expanded
|
||||
const { data: targetData } = useQuery({
|
||||
queryKey: ['scst-target', target.id],
|
||||
@@ -303,6 +335,119 @@ function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) {
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => scstAPI.deleteTarget(target.id),
|
||||
onSuccess: async () => {
|
||||
// Close expanded view if this target was expanded
|
||||
if (isExpanded) {
|
||||
onToggle()
|
||||
}
|
||||
// Call onDelete callback if provided
|
||||
if (onDelete) {
|
||||
onDelete()
|
||||
}
|
||||
|
||||
// Optimistically remove target from cache immediately
|
||||
queryClient.setQueryData<SCSTTarget[]>(['scst-targets'], (oldData) => {
|
||||
if (!oldData) return oldData
|
||||
return oldData.filter(t => t.id !== target.id)
|
||||
})
|
||||
|
||||
// Remove target-specific queries from cache
|
||||
queryClient.removeQueries({ queryKey: ['scst-target', target.id] })
|
||||
|
||||
// Invalidate and refetch targets list to ensure consistency
|
||||
await queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ['scst-targets'],
|
||||
type: 'active' // Only refetch active queries
|
||||
})
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// On error, refetch to restore correct state
|
||||
queryClient.refetchQueries({ queryKey: ['scst-targets'] })
|
||||
alert(`Failed to delete target: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const removeLUNMutation = useMutation({
|
||||
mutationFn: ({ targetId, lunId }: { targetId: string; lunId: string }) =>
|
||||
scstAPI.removeLUN(targetId, lunId),
|
||||
onMutate: async ({ lunId }) => {
|
||||
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await queryClient.cancelQueries({ queryKey: ['scst-target', target.id] })
|
||||
await queryClient.cancelQueries({ queryKey: ['scst-targets'] })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousTarget = queryClient.getQueryData(['scst-target', target.id])
|
||||
const previousTargets = queryClient.getQueryData<SCSTTarget[]>(['scst-targets'])
|
||||
|
||||
// Optimistically update to remove the LUN from target
|
||||
queryClient.setQueryData(['scst-target', target.id], (old: any) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
luns: old.luns ? old.luns.filter((lun: any) => lun.id !== lunId) : []
|
||||
}
|
||||
})
|
||||
|
||||
// Optimistically update LUN count in targets list
|
||||
queryClient.setQueryData<SCSTTarget[]>(['scst-targets'], (old) => {
|
||||
if (!old) return old
|
||||
return old.map(t =>
|
||||
t.id === target.id
|
||||
? { ...t, lun_count: Math.max(0, (t.lun_count || 0) - 1) }
|
||||
: t
|
||||
)
|
||||
})
|
||||
|
||||
return { previousTarget, previousTargets }
|
||||
},
|
||||
onSuccess: async () => {
|
||||
// Remove target-specific queries from cache
|
||||
queryClient.removeQueries({ queryKey: ['scst-target', target.id] })
|
||||
|
||||
// Invalidate and refetch queries to update UI
|
||||
await queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['scst-target', target.id] })
|
||||
|
||||
// Explicitly refetch the target data if the target is expanded
|
||||
if (isExpanded) {
|
||||
await queryClient.refetchQueries({ queryKey: ['scst-target', target.id] })
|
||||
}
|
||||
|
||||
// Refetch targets list to update LUN count
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ['scst-targets'],
|
||||
type: 'active'
|
||||
})
|
||||
},
|
||||
onError: (error: any, _variables, context) => {
|
||||
// If 404, treat as success (LUN already deleted)
|
||||
if (error.response?.status === 404) {
|
||||
// LUN already deleted, just refresh to sync UI
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-target', target.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
queryClient.refetchQueries({ queryKey: ['scst-target', target.id] })
|
||||
queryClient.refetchQueries({ queryKey: ['scst-targets'] })
|
||||
return
|
||||
}
|
||||
|
||||
// Rollback optimistic update
|
||||
if (context?.previousTarget) {
|
||||
queryClient.setQueryData(['scst-target', target.id], context.previousTarget)
|
||||
}
|
||||
if (context?.previousTargets) {
|
||||
queryClient.setQueryData<SCSTTarget[]>(['scst-targets'], context.previousTargets)
|
||||
}
|
||||
|
||||
// On error, refetch to restore correct state
|
||||
queryClient.refetchQueries({ queryKey: ['scst-target', target.id] })
|
||||
queryClient.refetchQueries({ queryKey: ['scst-targets'] })
|
||||
alert(`Failed to remove LUN: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={`group border-b border-border-dark ${isExpanded ? 'bg-white/[0.02]' : 'bg-transparent'}`}>
|
||||
{/* Main Row */}
|
||||
@@ -424,6 +569,18 @@ function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) {
|
||||
{lun.device_type || 'Unknown type'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Remove LUN ${lun.lun_number} from this target?`)) {
|
||||
removeLUNMutation.mutate({ targetId: target.id, lunId: lun.id })
|
||||
}
|
||||
}}
|
||||
disabled={removeLUNMutation.isPending}
|
||||
className="p-1.5 hover:bg-red-500/10 rounded text-text-secondary hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
title="Remove LUN"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
@@ -447,6 +604,19 @@ function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) {
|
||||
>
|
||||
Edit Policy
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`Delete target "${target.alias || target.iqn}"? This will remove the target from SCST and all associated LUNs and initiators. This action cannot be undone.`)) {
|
||||
deleteMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-3 py-1.5 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded border border-red-500/20 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete Target
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<div className="p-3 rounded bg-card-dark border border-border-dark flex flex-col gap-2">
|
||||
@@ -540,6 +710,18 @@ function EditPolicyModal({ target, initiatorGroups, onClose, onSuccess }: EditPo
|
||||
},
|
||||
})
|
||||
|
||||
const removeInitiatorMutation = useMutation({
|
||||
mutationFn: (initiatorId: string) => scstAPI.removeInitiator(initiatorId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-target', target.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-initiators'] })
|
||||
onSuccess()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Failed to remove initiator: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleAddInitiator = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!initiatorIQN.trim()) {
|
||||
@@ -620,10 +802,11 @@ function EditPolicyModal({ target, initiatorGroups, onClose, onSuccess }: EditPo
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Remove initiator ${initiator.iqn}?`)) {
|
||||
alert('Remove initiator functionality coming soon')
|
||||
removeInitiatorMutation.mutate(initiator.id)
|
||||
}
|
||||
}}
|
||||
className="p-2 hover:bg-red-500/10 rounded-lg text-text-secondary hover:text-red-400 transition-colors"
|
||||
disabled={removeInitiatorMutation.isPending}
|
||||
className="p-2 hover:bg-red-500/10 rounded-lg text-text-secondary hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Remove initiator"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
@@ -682,10 +865,14 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: scstAPI.createTarget,
|
||||
onSuccess: async () => {
|
||||
// Invalidate and refetch targets list
|
||||
onSuccess: async (newTarget) => {
|
||||
// Invalidate and refetch targets list to ensure we get the latest data
|
||||
await queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['scst-targets'] })
|
||||
// Also invalidate the specific target query if it exists
|
||||
if (newTarget?.id) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['scst-target', newTarget.id] })
|
||||
}
|
||||
onSuccess()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
@@ -1164,19 +1351,21 @@ function InitiatorsTab() {
|
||||
{initiator.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-text-secondary">
|
||||
<div className="flex items-center gap-4 text-xs text-text-secondary flex-wrap mt-1">
|
||||
{initiator.target_iqn && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">Target:</span>
|
||||
<span className="font-mono truncate max-w-[300px]">
|
||||
{initiator.target_name || initiator.target_iqn}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-semibold text-text-secondary/80">Target:</span>
|
||||
<span className="font-mono text-white/90 truncate max-w-[300px]" title={initiator.target_iqn}>
|
||||
{initiator.target_name || initiator.target_iqn.split(':').pop()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{initiator.group_name && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">Group:</span>
|
||||
<span className="truncate">{initiator.group_name}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-semibold text-text-secondary/80">Group:</span>
|
||||
<span className="font-mono text-white/90 truncate max-w-[300px]" title={initiator.group_name}>
|
||||
{initiator.group_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1230,7 +1419,15 @@ function ExtentsTab() {
|
||||
|
||||
const { data: extents = [], isLoading } = useQuery<SCSTExtent[]>({
|
||||
queryKey: ['scst-extents'],
|
||||
queryFn: scstAPI.listExtents,
|
||||
queryFn: () => scstAPI.listExtents(), // Wrap in arrow function to ensure fresh call
|
||||
refetchInterval: 3000, // Auto-refresh every 3 seconds
|
||||
refetchIntervalInBackground: true, // Continue refetching even when tab is in background
|
||||
refetchOnWindowFocus: true, // Refetch when window regains focus
|
||||
refetchOnMount: true, // Always refetch on mount
|
||||
refetchOnReconnect: true, // Refetch when network reconnects
|
||||
staleTime: 0, // Consider data stale immediately to ensure fresh data
|
||||
gcTime: 0, // Don't cache data (formerly cacheTime)
|
||||
structuralSharing: false, // Disable structural sharing to ensure updates are detected
|
||||
})
|
||||
|
||||
const { data: handlersData } = useQuery({
|
||||
@@ -1247,11 +1444,50 @@ function ExtentsTab() {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: scstAPI.deleteExtent,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-extents'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
onMutate: async (deviceName: string) => {
|
||||
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await queryClient.cancelQueries({ queryKey: ['scst-extents'] })
|
||||
await queryClient.cancelQueries({ queryKey: ['scst-targets'] })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousExtents = queryClient.getQueryData<SCSTExtent[]>(['scst-extents'])
|
||||
|
||||
// Optimistically update to remove the extent
|
||||
queryClient.setQueryData<SCSTExtent[]>(['scst-extents'], (old) =>
|
||||
old ? old.filter((e) => e.device_name !== deviceName) : []
|
||||
)
|
||||
|
||||
return { previousExtents }
|
||||
},
|
||||
onError: (error: any) => {
|
||||
onSuccess: async () => {
|
||||
// Remove all queries from cache (including inactive ones)
|
||||
queryClient.removeQueries({ queryKey: ['scst-extents'] })
|
||||
queryClient.removeQueries({ queryKey: ['scst-targets'] })
|
||||
|
||||
// Invalidate all related queries
|
||||
await queryClient.invalidateQueries({ queryKey: ['scst-extents'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
|
||||
// Force refetch with no cache
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ['scst-extents'],
|
||||
type: 'all' // Refetch all queries, not just active
|
||||
})
|
||||
|
||||
// Also refetch targets to update LUN count if needed
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ['scst-targets'],
|
||||
type: 'all'
|
||||
})
|
||||
},
|
||||
onError: (error: any, _deviceName: string, context) => {
|
||||
// Rollback optimistic update
|
||||
if (context?.previousExtents) {
|
||||
queryClient.setQueryData<SCSTExtent[]>(['scst-extents'], context.previousExtents)
|
||||
}
|
||||
|
||||
// Refetch to restore correct state
|
||||
queryClient.refetchQueries({ queryKey: ['scst-extents'] })
|
||||
alert(`Failed to delete extent: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
@@ -1383,9 +1619,16 @@ function ExtentsTab() {
|
||||
<CreateExtentModal
|
||||
handlers={handlers}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
onSuccess={async () => {
|
||||
setShowCreateModal(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-extents'] })
|
||||
// Remove queries from cache
|
||||
queryClient.removeQueries({ queryKey: ['scst-extents'] })
|
||||
// Force refetch to ensure fresh data
|
||||
await queryClient.invalidateQueries({ queryKey: ['scst-extents'] })
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ['scst-extents'],
|
||||
type: 'active'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1393,15 +1636,16 @@ function ExtentsTab() {
|
||||
)
|
||||
}
|
||||
|
||||
function CreateExtentModal({ handlers, onClose, onSuccess }: { handlers: Array<{ name: string; label: string; description?: string }>, onClose: () => void, onSuccess: () => void }) {
|
||||
function CreateExtentModal({ handlers, onClose, onSuccess }: { handlers: Array<{ name: string; label: string; description?: string }>, onClose: () => void, onSuccess: () => Promise<void> }) {
|
||||
const [deviceName, setDeviceName] = useState('')
|
||||
const [devicePath, setDevicePath] = useState('')
|
||||
const [handlerType, setHandlerType] = useState('')
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateExtentRequest) => scstAPI.createExtent(data),
|
||||
onSuccess: () => {
|
||||
onSuccess()
|
||||
onSuccess: async () => {
|
||||
// Call onSuccess callback which will handle refresh
|
||||
await onSuccess()
|
||||
alert('Extent created successfully!')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
@@ -1512,3 +1756,557 @@ function CreateExtentModal({ handlers, onClose, onSuccess }: { handlers: Array<{
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InitiatorGroupsTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [editingGroup, setEditingGroup] = useState<SCSTInitiatorGroup | null>(null)
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null)
|
||||
const [showAddInitiatorModal, setShowAddInitiatorModal] = useState<string | null>(null)
|
||||
|
||||
const { data: groups = [], isLoading } = useQuery<SCSTInitiatorGroup[]>({
|
||||
queryKey: ['scst-initiator-groups'],
|
||||
queryFn: scstAPI.listInitiatorGroups,
|
||||
refetchInterval: 3000, // Auto-refresh every 3 seconds
|
||||
refetchIntervalInBackground: true, // Continue refetching even when tab is in background
|
||||
refetchOnWindowFocus: true, // Refetch when window regains focus
|
||||
refetchOnMount: true, // Always refetch on mount
|
||||
refetchOnReconnect: true, // Refetch when network reconnects
|
||||
staleTime: 0, // Consider data stale immediately to ensure fresh data
|
||||
gcTime: 0, // Don't cache data (formerly cacheTime)
|
||||
structuralSharing: false, // Disable structural sharing to ensure updates are detected
|
||||
})
|
||||
|
||||
const { data: targets = [] } = useQuery<SCSTTarget[]>({
|
||||
queryKey: ['scst-targets'],
|
||||
queryFn: scstAPI.listTargets,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { target_id: string; group_name: string }) => scstAPI.createInitiatorGroup(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-initiator-groups'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
setShowCreateModal(false)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Failed to create group: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: { group_name: string } }) => scstAPI.updateInitiatorGroup(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-initiator-groups'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
setEditingGroup(null)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Failed to update group: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => scstAPI.deleteInitiatorGroup(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-initiator-groups'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Failed to delete group: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const addInitiatorMutation = useMutation({
|
||||
mutationFn: ({ groupId, initiatorIQN }: { groupId: string; initiatorIQN: string }) =>
|
||||
scstAPI.addInitiatorToGroup(groupId, initiatorIQN),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-initiator-groups'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-initiators'] })
|
||||
setShowAddInitiatorModal(null)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Failed to add initiator: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const removeInitiatorMutation = useMutation({
|
||||
mutationFn: (initiatorId: string) => scstAPI.removeInitiator(initiatorId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-initiator-groups'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-initiators'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Failed to remove initiator: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const filteredGroups = groups.filter(group => {
|
||||
const target = targets.find(t => t.id === group.target_id)
|
||||
const matchesSearch =
|
||||
group.group_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(target && ((target.alias || target.iqn).toLowerCase().includes(searchQuery.toLowerCase()) || target.iqn.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||
return matchesSearch
|
||||
})
|
||||
|
||||
const handleDelete = (group: SCSTInitiatorGroup) => {
|
||||
if (group.initiators && group.initiators.length > 0) {
|
||||
alert(`Cannot delete group: Group contains ${group.initiators.length} initiator(s). Please remove all initiators first.`)
|
||||
return
|
||||
}
|
||||
if (confirm(`Delete initiator group "${group.group_name}"?`)) {
|
||||
deleteMutation.mutate(group.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-white text-2xl font-bold">iSCSI Initiator Groups</h2>
|
||||
<p className="text-text-secondary text-sm mt-1">Manage initiator access control groups</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="p-4 flex items-center justify-between gap-4 border-b border-border-dark/50 bg-[#141d26]">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search groups by name or target..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-[#0f161d] border border-border-dark rounded-lg pl-10 pr-4 py-2 text-sm text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all placeholder-text-secondary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Groups List */}
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-text-secondary">Loading groups...</div>
|
||||
) : filteredGroups.length > 0 ? (
|
||||
<div className="bg-[#141d26] border border-border-dark rounded-lg overflow-hidden">
|
||||
<div className="divide-y divide-border-dark">
|
||||
{filteredGroups.map((group) => {
|
||||
const target = targets.find(t => t.id === group.target_id)
|
||||
const isExpanded = expandedGroup === group.id
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className="border-b border-border-dark last:border-b-0"
|
||||
>
|
||||
<div className="p-4 hover:bg-white/5 transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<button
|
||||
onClick={() => setExpandedGroup(isExpanded ? null : group.id)}
|
||||
className="p-2 rounded-md bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
|
||||
</button>
|
||||
<div className="p-2 rounded-md bg-primary/10 text-primary">
|
||||
<Network size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-white font-mono text-sm font-medium">
|
||||
{group.group_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-text-secondary">
|
||||
{target && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">Target:</span>
|
||||
<span className="font-mono truncate max-w-[300px]" title={target.iqn}>
|
||||
{target.alias || target.iqn.split(':').pop()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">Initiators:</span>
|
||||
<span className="text-white/90">
|
||||
{group.initiators?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">Created:</span>
|
||||
<span>{new Date(group.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setEditingGroup(group)}
|
||||
className="p-2 hover:bg-white/10 rounded-lg text-text-secondary hover:text-white transition-colors"
|
||||
title="Edit group name"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(group)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-2 hover:bg-red-500/10 rounded-lg text-text-secondary hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
title="Delete group"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded view with initiators list */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 bg-[#0f161d] border-t border-border-dark">
|
||||
<div className="flex items-center justify-between mb-3 mt-3">
|
||||
<h4 className="text-white text-sm font-semibold">Group Members</h4>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowAddInitiatorModal(group.id)}
|
||||
variant="outline"
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
Add Initiator
|
||||
</Button>
|
||||
</div>
|
||||
{group.initiators && group.initiators.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{group.initiators.map((initiator) => (
|
||||
<div
|
||||
key={initiator.id}
|
||||
className="flex items-center justify-between p-3 bg-[#141d26] border border-border-dark rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="p-1.5 rounded bg-primary/10 text-primary">
|
||||
<Network size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-mono text-xs truncate">
|
||||
{initiator.iqn}
|
||||
</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ${
|
||||
initiator.is_active
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{initiator.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(initiator.iqn)
|
||||
}}
|
||||
className="p-1.5 hover:bg-white/10 rounded text-text-secondary hover:text-white transition-colors"
|
||||
title="Copy IQN"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Remove initiator "${initiator.iqn}" from this group?`)) {
|
||||
removeInitiatorMutation.mutate(initiator.id)
|
||||
}
|
||||
}}
|
||||
disabled={removeInitiatorMutation.isPending}
|
||||
className="p-1.5 hover:bg-red-500/10 rounded text-text-secondary hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
title="Remove initiator"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center border border-border-dark rounded-lg bg-[#141d26]">
|
||||
<p className="text-text-secondary text-sm mb-2">No initiators in this group</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowAddInitiatorModal(group.id)}
|
||||
variant="outline"
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
Add First Initiator
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-border-dark/50 mb-4">
|
||||
<Network className="text-text-secondary" size={32} />
|
||||
</div>
|
||||
<p className="text-white font-medium mb-1">No groups found</p>
|
||||
<p className="text-text-secondary text-sm">
|
||||
{searchQuery
|
||||
? 'Try adjusting your search criteria'
|
||||
: 'Create an initiator group to organize initiators by access control'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Group Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateGroupModal
|
||||
targets={targets}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
isLoading={createMutation.isPending}
|
||||
onSubmit={(data) => createMutation.mutate(data)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Group Modal */}
|
||||
{editingGroup && (
|
||||
<EditGroupModal
|
||||
group={editingGroup}
|
||||
onClose={() => setEditingGroup(null)}
|
||||
isLoading={updateMutation.isPending}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: editingGroup.id, data })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Initiator Modal */}
|
||||
{showAddInitiatorModal && (
|
||||
<AddInitiatorToGroupModal
|
||||
groupName={groups.find(g => g.id === showAddInitiatorModal)?.group_name || ''}
|
||||
onClose={() => setShowAddInitiatorModal(null)}
|
||||
isLoading={addInitiatorMutation.isPending}
|
||||
onSubmit={(initiatorIQN) => addInitiatorMutation.mutate({ groupId: showAddInitiatorModal, initiatorIQN })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateGroupModal({ targets, onClose, isLoading, onSubmit }: {
|
||||
targets: SCSTTarget[]
|
||||
onClose: () => void
|
||||
isLoading: boolean
|
||||
onSubmit: (data: { target_id: string; group_name: string }) => void
|
||||
}) {
|
||||
const [targetId, setTargetId] = useState('')
|
||||
const [groupName, setGroupName] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!targetId || !groupName.trim()) {
|
||||
alert('Please fill in all required fields')
|
||||
return
|
||||
}
|
||||
onSubmit({ target_id: targetId, group_name: groupName.trim() })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[#141d26] border border-border-dark rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-white text-lg font-bold">Create Initiator Group</h3>
|
||||
<button onClick={onClose} className="text-text-secondary hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Target *
|
||||
</label>
|
||||
<select
|
||||
value={targetId}
|
||||
onChange={(e) => setTargetId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
>
|
||||
<option value="">Select a target</option>
|
||||
{targets.map(target => (
|
||||
<option key={target.id} value={target.id} className="bg-[#0f161d] text-white">
|
||||
{target.alias || target.iqn.split(':').pop()} ({target.iqn})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Group Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
placeholder="my-acl-group"
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-text-secondary mt-1">
|
||||
Group name will be used as ACL group name in SCST
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 mt-6">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditGroupModal({ group, onClose, isLoading, onSubmit }: {
|
||||
group: SCSTInitiatorGroup
|
||||
onClose: () => void
|
||||
isLoading: boolean
|
||||
onSubmit: (data: { group_name: string }) => void
|
||||
}) {
|
||||
const [groupName, setGroupName] = useState(group.group_name)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!groupName.trim()) {
|
||||
alert('Group name cannot be empty')
|
||||
return
|
||||
}
|
||||
onSubmit({ group_name: groupName.trim() })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[#141d26] border border-border-dark rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-white text-lg font-bold">Edit Initiator Group</h3>
|
||||
<button onClick={onClose} className="text-text-secondary hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Group Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
placeholder="my-acl-group"
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-text-secondary mt-1">
|
||||
Changing the group name will recreate it in SCST
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 mt-6">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddInitiatorToGroupModal({ groupName, onClose, isLoading, onSubmit }: {
|
||||
groupName: string
|
||||
onClose: () => void
|
||||
isLoading: boolean
|
||||
onSubmit: (initiatorIQN: string) => void
|
||||
}) {
|
||||
const [initiatorIQN, setInitiatorIQN] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!initiatorIQN.trim()) {
|
||||
alert('Please enter an initiator IQN')
|
||||
return
|
||||
}
|
||||
// Validate IQN format (basic check)
|
||||
if (!initiatorIQN.trim().toLowerCase().startsWith('iqn.')) {
|
||||
alert('Invalid IQN format. IQN must start with "iqn."')
|
||||
return
|
||||
}
|
||||
onSubmit(initiatorIQN.trim())
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[#141d26] border border-border-dark rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-white text-lg font-bold">Add Initiator to Group</h3>
|
||||
<button onClick={onClose} className="text-text-secondary hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Group
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={groupName}
|
||||
disabled
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white/60 text-sm cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Initiator IQN *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={initiatorIQN}
|
||||
onChange={(e) => setInitiatorIQN(e.target.value)}
|
||||
placeholder="iqn.1993-08.org.debian:01:example"
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary font-mono"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-text-secondary mt-1">
|
||||
Enter the IQN of the initiator to add to this group
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 mt-6">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Adding...' : 'Add Initiator'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,21 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
-webkit-box-shadow: 0 0 0 30px #111a22 inset !important;
|
||||
-webkit-text-fill-color: #ffffff !important;
|
||||
box-shadow: 0 0 0 30px #111a22 inset !important;
|
||||
caret-color: #ffffff !important;
|
||||
}
|
||||
input:-webkit-autofill::first-line {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
`}</style>
|
||||
<div className="min-h-screen flex items-center justify-center bg-background-dark">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-card-dark border border-border-dark rounded-lg shadow-md">
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -73,10 +88,11 @@ export default function LoginPage() {
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-border-dark bg-[#111a22] placeholder-text-secondary text-white rounded-t-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-border-dark bg-[#111a22] placeholder-text-secondary text-white rounded-t-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm autofill:bg-[#111a22] autofill:text-white"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -88,10 +104,11 @@ export default function LoginPage() {
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-border-dark bg-[#111a22] placeholder-text-secondary text-white rounded-b-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-border-dark bg-[#111a22] placeholder-text-secondary text-white rounded-b-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm autofill:bg-[#111a22] autofill:text-white"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,6 +138,7 @@ export default function LoginPage() {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { systemAPI, NetworkInterface } from '@/api/system'
|
||||
|
||||
export default function System() {
|
||||
const [snmpEnabled, setSnmpEnabled] = useState(false)
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null)
|
||||
const [editingInterface, setEditingInterface] = useState<NetworkInterface | null>(null)
|
||||
const [viewingInterface, setViewingInterface] = useState<NetworkInterface | null>(null)
|
||||
const [timezone, setTimezone] = useState('Etc/UTC')
|
||||
const [ntpServers, setNtpServers] = useState<string[]>(['pool.ntp.org', 'time.google.com'])
|
||||
const [showAddNtpServer, setShowAddNtpServer] = useState(false)
|
||||
const [newNtpServer, setNewNtpServer] = useState('')
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Save NTP settings mutation
|
||||
const saveNTPSettingsMutation = useMutation({
|
||||
mutationFn: (data: { timezone: string; ntp_servers: string[] }) => systemAPI.saveNTPSettings(data),
|
||||
onSuccess: () => {
|
||||
// Refetch NTP settings to get the updated values
|
||||
queryClient.invalidateQueries({ queryKey: ['system', 'ntp'] })
|
||||
// Show success message (you can add a toast notification here)
|
||||
alert('NTP settings saved successfully!')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Failed to save NTP settings: ${error.message || 'Unknown error'}`)
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch network interfaces
|
||||
const { data: interfaces = [], isLoading: interfacesLoading } = useQuery({
|
||||
@@ -13,6 +37,39 @@ export default function System() {
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
})
|
||||
|
||||
// Fetch services
|
||||
const { data: services = [], isLoading: servicesLoading } = useQuery({
|
||||
queryKey: ['system', 'services'],
|
||||
queryFn: () => systemAPI.listServices(),
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
})
|
||||
|
||||
|
||||
// Fetch NTP settings on mount
|
||||
const { data: ntpSettings } = useQuery({
|
||||
queryKey: ['system', 'ntp'],
|
||||
queryFn: () => systemAPI.getNTPSettings(),
|
||||
})
|
||||
|
||||
// Update state when NTP settings are loaded
|
||||
useEffect(() => {
|
||||
if (ntpSettings) {
|
||||
setTimezone(ntpSettings.timezone)
|
||||
setNtpServers(ntpSettings.ntp_servers)
|
||||
}
|
||||
}, [ntpSettings])
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpenMenu(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
|
||||
{/* Top Navigation */}
|
||||
@@ -133,9 +190,51 @@ export default function System() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="h-8 w-8 rounded-full hover:bg-border-dark flex items-center justify-center text-text-secondary hover:text-white transition-colors">
|
||||
<span className="material-symbols-outlined">more_vert</span>
|
||||
</button>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setOpenMenu(openMenu === iface.name ? null : iface.name)}
|
||||
className="h-8 w-8 rounded-full hover:bg-border-dark flex items-center justify-center text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined">more_vert</span>
|
||||
</button>
|
||||
{openMenu === iface.name && (
|
||||
<div className="absolute right-0 mt-1 w-48 rounded-lg border border-border-dark bg-card-dark shadow-lg z-50">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingInterface(iface)
|
||||
setOpenMenu(null)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-white hover:bg-border-dark transition-colors first:rounded-t-lg"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
<span>Edit Connection</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewingInterface(iface)
|
||||
setOpenMenu(null)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-white hover:bg-border-dark transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">info</span>
|
||||
<span>View Details</span>
|
||||
</button>
|
||||
<div className="border-t border-border-dark"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement disable/enable
|
||||
setOpenMenu(null)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-white hover:bg-border-dark transition-colors last:rounded-b-lg"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{isConnected ? 'toggle_on' : 'toggle_off'}
|
||||
</span>
|
||||
<span>{isConnected ? 'Disable' : 'Enable'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -157,145 +256,65 @@ export default function System() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-1">
|
||||
{/* Service Row */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">terminal</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">SSH Service</p>
|
||||
<p className="text-xs text-text-secondary">Remote command line access</p>
|
||||
</div>
|
||||
{servicesLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-text-secondary">Loading services...</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
|
||||
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input
|
||||
defaultChecked
|
||||
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||
id="ssh-toggle"
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||
htmlFor="ssh-toggle"
|
||||
></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Service Row */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">folder_shared</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">SMB / CIFS</p>
|
||||
<p className="text-xs text-text-secondary">Windows file sharing</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
|
||||
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input
|
||||
defaultChecked
|
||||
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||
id="smb-toggle"
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||
htmlFor="smb-toggle"
|
||||
></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Service Row */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">storage</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">iSCSI Target</p>
|
||||
<p className="text-xs text-text-secondary">Block storage sharing</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-yellow-500/20 text-yellow-500 border border-yellow-500/20">STOPPED</span>
|
||||
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input
|
||||
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||
id="iscsi-toggle"
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||
htmlFor="iscsi-toggle"
|
||||
></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Service Row */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">share</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">NFS Service</p>
|
||||
<p className="text-xs text-text-secondary">Unix file sharing</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
|
||||
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input
|
||||
defaultChecked
|
||||
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||
id="nfs-toggle"
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||
htmlFor="nfs-toggle"
|
||||
></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Service Row - VTL (MHVTL) */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">album</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">VTL Service</p>
|
||||
<p className="text-xs text-text-secondary">Virtual tape library emulation</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
|
||||
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input
|
||||
defaultChecked
|
||||
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||
id="mhvtl-toggle"
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||
htmlFor="mhvtl-toggle"
|
||||
></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Service configs to display - map backend service names to display configs
|
||||
[
|
||||
{ key: 'ssh', serviceNames: ['ssh', 'sshd'], displayName: 'SSH Service', description: 'Remote command line access', icon: 'terminal' },
|
||||
{ key: 'smb', serviceNames: ['smbd', 'samba', 'smb'], displayName: 'SMB / CIFS', description: 'Windows file sharing', icon: 'folder_shared' },
|
||||
{ key: 'iscsi', serviceNames: ['iscsi-scst', 'iscsi', 'scst'], displayName: 'iSCSI Target', description: 'Block storage sharing', icon: 'storage' },
|
||||
{ key: 'nfs', serviceNames: ['nfs-server', 'nfs', 'nfsd'], displayName: 'NFS Service', description: 'Unix file sharing', icon: 'share' },
|
||||
{ key: 'vtl', serviceNames: ['mhvtl', 'vtl'], displayName: 'VTL Service', description: 'Virtual tape library emulation', icon: 'album' },
|
||||
].map((config) => {
|
||||
const service = services.find(s => {
|
||||
const serviceNameLower = s.name.toLowerCase()
|
||||
return config.serviceNames.some(name => serviceNameLower.includes(name.toLowerCase()) || name.toLowerCase().includes(serviceNameLower))
|
||||
})
|
||||
const isActive = service?.active_state === 'active'
|
||||
const status = isActive ? 'RUNNING' : 'STOPPED'
|
||||
const statusColor = isActive ? 'bg-green-500/20 text-green-500 border-green-500/20' : 'bg-yellow-500/20 text-yellow-500 border-yellow-500/20'
|
||||
|
||||
return (
|
||||
<div key={config.key} className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">{config.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">{config.displayName}</p>
|
||||
<p className="text-xs text-text-secondary">{config.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold border ${statusColor}`}>{status}</span>
|
||||
<label className="relative inline-block w-10 h-5 mr-2 align-middle select-none cursor-pointer">
|
||||
<input
|
||||
checked={isActive}
|
||||
onChange={() => {
|
||||
if (service) {
|
||||
systemAPI.restartService(service.name).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['system', 'services'] })
|
||||
}).catch((err) => {
|
||||
alert(`Failed to ${isActive ? 'stop' : 'start'} service: ${err.message || 'Unknown error'}`)
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="sr-only peer"
|
||||
id={`${config.key}-toggle`}
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="absolute inset-0 rounded-full bg-border-dark transition-colors duration-300 peer-checked:bg-primary/20"></span>
|
||||
<span className="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition-transform duration-300 peer-checked:translate-x-5"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -306,18 +325,42 @@ export default function System() {
|
||||
<span className="material-symbols-outlined text-primary">schedule</span>
|
||||
<h2 className="text-lg font-bold text-white">Date & Time</h2>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-text-secondary bg-border-dark px-2 py-1 rounded">UTC</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
saveNTPSettingsMutation.mutate({
|
||||
timezone,
|
||||
ntp_servers: ntpServers,
|
||||
})
|
||||
}}
|
||||
disabled={saveNTPSettingsMutation.isPending}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-bold text-white hover:bg-blue-600 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">save</span>
|
||||
{saveNTPSettingsMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">System Timezone</label>
|
||||
<div className="relative">
|
||||
<select className="block w-full rounded-lg border-border-dark bg-[#111a22] py-2.5 pl-3 pr-10 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary appearance-none">
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="block w-full rounded-lg border-border-dark bg-[#111a22] py-2.5 pl-3 pr-10 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary appearance-none"
|
||||
>
|
||||
<option>Etc/UTC</option>
|
||||
<option>America/New_York</option>
|
||||
<option>Europe/London</option>
|
||||
<option>Asia/Jakarta</option>
|
||||
<option>Asia/Singapore</option>
|
||||
<option>Asia/Bangkok</option>
|
||||
<option>Asia/Manila</option>
|
||||
<option>Asia/Tokyo</option>
|
||||
<option>Asia/Shanghai</option>
|
||||
<option>Asia/Hong_Kong</option>
|
||||
<option>Europe/London</option>
|
||||
<option>Europe/Paris</option>
|
||||
<option>America/New_York</option>
|
||||
<option>America/Los_Angeles</option>
|
||||
</select>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-white">
|
||||
<span className="material-symbols-outlined text-sm">expand_more</span>
|
||||
@@ -328,25 +371,79 @@ export default function System() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-xs font-medium text-text-secondary uppercase">NTP Servers</label>
|
||||
<button className="text-xs text-primary font-bold hover:text-white flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowAddNtpServer(true)}
|
||||
className="text-xs text-primary font-bold hover:text-white flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">add</span> Add Server
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-border-dark">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||
<span className="text-sm font-mono text-white">pool.ntp.org</span>
|
||||
{showAddNtpServer && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-[#111a22] p-3 border border-border-dark">
|
||||
<input
|
||||
type="text"
|
||||
value={newNtpServer}
|
||||
onChange={(e) => setNewNtpServer(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newNtpServer.trim()) {
|
||||
if (!ntpServers.includes(newNtpServer.trim())) {
|
||||
setNtpServers([...ntpServers, newNtpServer.trim()])
|
||||
setNewNtpServer('')
|
||||
setShowAddNtpServer(false)
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setNewNtpServer('')
|
||||
setShowAddNtpServer(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Enter NTP server address (e.g., 0.pool.ntp.org)"
|
||||
className="flex-1 bg-transparent text-sm text-white placeholder-gray-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newNtpServer.trim() && !ntpServers.includes(newNtpServer.trim())) {
|
||||
setNtpServers([...ntpServers, newNtpServer.trim()])
|
||||
setNewNtpServer('')
|
||||
setShowAddNtpServer(false)
|
||||
}
|
||||
}}
|
||||
className="text-green-500 hover:text-green-400"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">check</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewNtpServer('')
|
||||
setShowAddNtpServer(false)
|
||||
}}
|
||||
className="text-red-500 hover:text-red-400"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">Stratum 2 • 12ms</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-border-dark">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-sm font-mono text-white">time.google.com</span>
|
||||
)}
|
||||
{ntpServers.map((server, index) => (
|
||||
<div key={index} className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-border-dark">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||
<span className="text-sm font-mono text-white">{server}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-secondary">Stratum 2 • 12ms</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNtpServers(ntpServers.filter((_, i) => i !== index))
|
||||
}}
|
||||
className="text-red-500 hover:text-red-400"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">Stratum 1 • 45ms</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,20 +464,18 @@ export default function System() {
|
||||
<h3 className="text-sm font-bold text-white">SNMP Monitoring</h3>
|
||||
<p className="text-xs text-text-secondary">Enable Simple Network Management Protocol</p>
|
||||
</div>
|
||||
<div className="relative inline-block w-10 align-middle select-none transition duration-200 ease-in">
|
||||
<label className="relative inline-block w-10 h-5 align-middle select-none cursor-pointer">
|
||||
<input
|
||||
checked={snmpEnabled}
|
||||
onChange={(e) => setSnmpEnabled(e.target.checked)}
|
||||
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
|
||||
className="sr-only peer"
|
||||
id="snmp-toggle"
|
||||
name="toggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
||||
htmlFor="snmp-toggle"
|
||||
></label>
|
||||
</div>
|
||||
<span className="absolute inset-0 rounded-full bg-border-dark transition-colors duration-300 peer-checked:bg-primary/20"></span>
|
||||
<span className="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition-transform duration-300 peer-checked:translate-x-5"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className={`grid grid-cols-1 gap-4 transition-opacity ${snmpEnabled ? 'opacity-100' : 'opacity-50 pointer-events-none'}`}>
|
||||
<div>
|
||||
@@ -425,6 +520,320 @@ export default function System() {
|
||||
<div className="h-10"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Connection Modal */}
|
||||
{editingInterface && (
|
||||
<EditConnectionModal
|
||||
interface={editingInterface}
|
||||
onClose={() => setEditingInterface(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* View Details Modal */}
|
||||
{viewingInterface && (
|
||||
<ViewDetailsModal
|
||||
interface={viewingInterface}
|
||||
onClose={() => setViewingInterface(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit Connection Modal Component
|
||||
interface EditConnectionModalProps {
|
||||
interface: NetworkInterface
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function EditConnectionModal({ interface: iface, onClose }: EditConnectionModalProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [formData, setFormData] = useState({
|
||||
ip_address: iface.ip_address || '',
|
||||
subnet: iface.subnet || '24',
|
||||
gateway: iface.gateway || '',
|
||||
dns1: iface.dns1 || '',
|
||||
dns2: iface.dns2 || '',
|
||||
role: iface.role || '',
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: { ip_address: string; subnet: string; gateway?: string; dns1?: string; dns2?: string; role?: string }) =>
|
||||
systemAPI.updateNetworkInterface(iface.name, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['system', 'interfaces'] })
|
||||
onClose()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Failed to update interface: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
updateMutation.mutate({
|
||||
ip_address: formData.ip_address,
|
||||
subnet: formData.subnet,
|
||||
gateway: formData.gateway || undefined,
|
||||
dns1: formData.dns1 || undefined,
|
||||
dns2: formData.dns2 || undefined,
|
||||
role: formData.role || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl rounded-xl border border-border-dark bg-card-dark shadow-xl">
|
||||
<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">settings_ethernet</span>
|
||||
<h2 className="text-lg font-bold text-white">Edit Connection - {iface.name}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 rounded-full hover:bg-border-dark flex items-center justify-center text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6">
|
||||
<div className="space-y-4">
|
||||
{/* IP Address */}
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">
|
||||
IP Address
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.ip_address}
|
||||
onChange={(e) => setFormData({ ...formData, ip_address: e.target.value })}
|
||||
className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
placeholder="192.168.1.100"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subnet Mask */}
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">
|
||||
Subnet Mask (CIDR)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subnet}
|
||||
onChange={(e) => setFormData({ ...formData, subnet: e.target.value })}
|
||||
className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
placeholder="24"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">Enter CIDR notation (e.g., 24 for 255.255.255.0)</p>
|
||||
</div>
|
||||
|
||||
{/* Gateway */}
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">
|
||||
Default Gateway
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.gateway}
|
||||
onChange={(e) => setFormData({ ...formData, gateway: e.target.value })}
|
||||
className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
placeholder="192.168.1.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* DNS Servers */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">
|
||||
Primary DNS
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.dns1}
|
||||
onChange={(e) => setFormData({ ...formData, dns1: e.target.value })}
|
||||
className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
placeholder="8.8.8.8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">
|
||||
Secondary DNS
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.dns2}
|
||||
onChange={(e) => setFormData({ ...formData, dns2: e.target.value })}
|
||||
className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
placeholder="8.8.4.4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">
|
||||
Interface Role
|
||||
</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
className="block w-full rounded-lg border-border-dark bg-[#111a22] py-2.5 pl-3 pr-10 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary appearance-none"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="Management">Management</option>
|
||||
<option value="ISCSI">iSCSI</option>
|
||||
<option value="Storage">Storage</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-bold text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-bold text-white hover:bg-blue-600 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">save</span>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// View Details Modal Component
|
||||
interface ViewDetailsModalProps {
|
||||
interface: NetworkInterface
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ViewDetailsModal({ interface: iface, onClose }: ViewDetailsModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl rounded-xl border border-border-dark bg-card-dark shadow-xl">
|
||||
<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">info</span>
|
||||
<h2 className="text-lg font-bold text-white">Interface Details - {iface.name}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 rounded-full hover:bg-border-dark flex items-center justify-center text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`h-3 w-3 rounded-full ${iface.status === 'Connected' ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<span className="text-sm font-medium text-text-secondary">Status</span>
|
||||
</div>
|
||||
<span className={`text-sm font-bold ${iface.status === 'Connected' ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{iface.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Network Configuration Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* IP Address */}
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">IP Address</label>
|
||||
<p className="text-sm font-mono text-white">{iface.ip_address || 'Not configured'}</p>
|
||||
</div>
|
||||
|
||||
{/* Subnet */}
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">Subnet Mask (CIDR)</label>
|
||||
<p className="text-sm font-mono text-white">/{iface.subnet || 'N/A'}</p>
|
||||
</div>
|
||||
|
||||
{/* Gateway */}
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">Default Gateway</label>
|
||||
<p className="text-sm font-mono text-white">{iface.gateway || 'Not configured'}</p>
|
||||
</div>
|
||||
|
||||
{/* Speed */}
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">Link Speed</label>
|
||||
<p className="text-sm font-mono text-white">{iface.speed || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DNS Servers */}
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-3 block text-xs font-medium text-text-secondary uppercase">DNS Servers</label>
|
||||
<div className="space-y-2">
|
||||
{iface.dns1 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-secondary">Primary:</span>
|
||||
<span className="text-sm font-mono text-white">{iface.dns1}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-secondary">Primary DNS: Not configured</p>
|
||||
)}
|
||||
{iface.dns2 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-secondary">Secondary:</span>
|
||||
<span className="text-sm font-mono text-white">{iface.dns2}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-secondary">Secondary DNS: Not configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interface Role */}
|
||||
{iface.role && (
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">Interface Role</label>
|
||||
<span className={`inline-block px-3 py-1 rounded text-xs font-bold uppercase ${
|
||||
iface.role === 'ISCSI'
|
||||
? 'bg-purple-500/20 text-purple-400 border border-purple-500/20'
|
||||
: 'bg-primary/20 text-primary border border-primary/20'
|
||||
}`}>
|
||||
{iface.role}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Network Address */}
|
||||
{iface.ip_address && iface.subnet && (
|
||||
<div className="p-4 rounded-lg bg-[#111a22] border border-border-dark">
|
||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">Full Network Address</label>
|
||||
<p className="text-sm font-mono text-white">{iface.ip_address}/{iface.subnet}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-primary px-6 py-2.5 text-sm font-bold text-white hover:bg-blue-600 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user