fix mostly bugs on system management, and user roles and group assignment
This commit is contained in:
Binary file not shown.
@@ -260,7 +260,18 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
|||||||
}
|
}
|
||||||
|
|
||||||
// System Management
|
// System Management
|
||||||
|
systemService := system.NewService(log)
|
||||||
systemHandler := system.NewHandler(log, tasks.NewEngine(db, 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 := protected.Group("/system")
|
||||||
systemGroup.Use(requirePermission("system", "read"))
|
systemGroup.Use(requirePermission("system", "read"))
|
||||||
{
|
{
|
||||||
@@ -268,8 +279,11 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
|||||||
systemGroup.GET("/services/:name", systemHandler.GetServiceStatus)
|
systemGroup.GET("/services/:name", systemHandler.GetServiceStatus)
|
||||||
systemGroup.POST("/services/:name/restart", systemHandler.RestartService)
|
systemGroup.POST("/services/:name/restart", systemHandler.RestartService)
|
||||||
systemGroup.GET("/services/:name/logs", systemHandler.GetServiceLogs)
|
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.POST("/support-bundle", systemHandler.GenerateSupportBundle)
|
||||||
systemGroup.GET("/interfaces", systemHandler.ListNetworkInterfaces)
|
systemGroup.GET("/interfaces", systemHandler.ListNetworkInterfaces)
|
||||||
|
systemGroup.PUT("/interfaces/:name", systemHandler.UpdateNetworkInterface)
|
||||||
systemGroup.GET("/ntp", systemHandler.GetNTPSettings)
|
systemGroup.GET("/ntp", systemHandler.GetNTPSettings)
|
||||||
systemGroup.POST("/ntp", systemHandler.SaveNTPSettings)
|
systemGroup.POST("/ntp", systemHandler.SaveNTPSettings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,11 +88,14 @@ func GetUserGroups(db *database.DB, userID string) ([]string, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var groupName string
|
var groupName string
|
||||||
if err := rows.Scan(&groupName); err != nil {
|
if err := rows.Scan(&groupName); err != nil {
|
||||||
return nil, err
|
return []string{}, err
|
||||||
}
|
}
|
||||||
groups = append(groups, groupName)
|
groups = append(groups, groupName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if groups == nil {
|
||||||
|
groups = []string{}
|
||||||
|
}
|
||||||
return groups, rows.Err()
|
return groups, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ func (h *Handler) ListUsers(c *gin.Context) {
|
|||||||
permissions, _ := GetUserPermissions(h.db, u.ID)
|
permissions, _ := GetUserPermissions(h.db, u.ID)
|
||||||
groups, _ := GetUserGroups(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{}{
|
users = append(users, map[string]interface{}{
|
||||||
"id": u.ID,
|
"id": u.ID,
|
||||||
"username": u.Username,
|
"username": u.Username,
|
||||||
@@ -138,6 +149,17 @@ func (h *Handler) GetUser(c *gin.Context) {
|
|||||||
permissions, _ := GetUserPermissions(h.db, userID)
|
permissions, _ := GetUserPermissions(h.db, userID)
|
||||||
groups, _ := GetUserGroups(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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"username": user.Username,
|
"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
|
// 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 {
|
if len(updates) == 1 && req.Roles == nil && req.Groups == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||||
return
|
return
|
||||||
@@ -259,13 +283,14 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
|||||||
|
|
||||||
// Update roles if provided
|
// Update roles if provided
|
||||||
if req.Roles != nil {
|
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)
|
currentRoles, err := GetUserRoles(h.db, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Failed to get current roles for user", "user_id", userID, "error", err)
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process user roles"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.logger.Info("Current user roles", "user_id", userID, "current_roles", currentRoles)
|
||||||
|
|
||||||
rolesToAdd := []string{}
|
rolesToAdd := []string{}
|
||||||
rolesToRemove := []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
|
// Add new roles
|
||||||
|
if len(rolesToAdd) == 0 {
|
||||||
|
h.logger.Info("No roles to add", "user_id", userID)
|
||||||
|
}
|
||||||
for _, roleName := range rolesToAdd {
|
for _, roleName := range rolesToAdd {
|
||||||
|
h.logger.Info("Processing role to add", "user_id", userID, "role_name", roleName)
|
||||||
roleID, err := GetRoleIDByName(h.db, roleName)
|
roleID, err := GetRoleIDByName(h.db, roleName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process roles"})
|
||||||
return
|
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 {
|
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)
|
h.logger.Error("Failed to add role to user", "user_id", userID, "role_id", roleID, "role_name", roleName, "error", err)
|
||||||
// Don't return early, continue with other roles
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add role '%s': %v", roleName, err)})
|
||||||
continue
|
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
|
// Remove old roles
|
||||||
@@ -415,8 +448,48 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("User updated", "user_id", userID)
|
// Fetch updated user data to return
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "user updated successfully"})
|
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
|
// DeleteUser deletes a user
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package iam
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/atlasos/calypso/internal/common/database"
|
"github.com/atlasos/calypso/internal/common/database"
|
||||||
@@ -90,11 +91,14 @@ func GetUserRoles(db *database.DB, userID string) ([]string, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var role string
|
var role string
|
||||||
if err := rows.Scan(&role); err != nil {
|
if err := rows.Scan(&role); err != nil {
|
||||||
return nil, err
|
return []string{}, err
|
||||||
}
|
}
|
||||||
roles = append(roles, role)
|
roles = append(roles, role)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if roles == nil {
|
||||||
|
roles = []string{}
|
||||||
|
}
|
||||||
return roles, rows.Err()
|
return roles, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,11 +122,14 @@ func GetUserPermissions(db *database.DB, userID string) ([]string, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var perm string
|
var perm string
|
||||||
if err := rows.Scan(&perm); err != nil {
|
if err := rows.Scan(&perm); err != nil {
|
||||||
return nil, err
|
return []string{}, err
|
||||||
}
|
}
|
||||||
permissions = append(permissions, perm)
|
permissions = append(permissions, perm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if permissions == nil {
|
||||||
|
permissions = []string{}
|
||||||
|
}
|
||||||
return permissions, rows.Err()
|
return permissions, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,8 +140,23 @@ func AddUserRole(db *database.DB, userID, roleID, assignedBy string) error {
|
|||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (user_id, role_id) DO NOTHING
|
ON CONFLICT (user_id, role_id) DO NOTHING
|
||||||
`
|
`
|
||||||
_, err := db.Exec(query, userID, roleID, assignedBy)
|
result, err := db.Exec(query, userID, roleID, assignedBy)
|
||||||
return err
|
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
|
// RemoveUserRole removes a role from a user
|
||||||
|
|||||||
@@ -173,3 +173,63 @@ func (h *Handler) GetNTPSettings(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
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})
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -20,16 +20,37 @@ type NTPSettings struct {
|
|||||||
|
|
||||||
// Service handles system management operations
|
// Service handles system management operations
|
||||||
type Service struct {
|
type Service struct {
|
||||||
logger *logger.Logger
|
logger *logger.Logger
|
||||||
|
rrdService *RRDService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new system service
|
// NewService creates a new system service
|
||||||
func NewService(log *logger.Logger) *Service {
|
func NewService(log *logger.Logger) *Service {
|
||||||
|
// Initialize RRD service for network monitoring (default to eth0, can be configured)
|
||||||
|
rrdDir := "/var/lib/calypso/rrd"
|
||||||
|
interfaceName := "eth0" // Default interface, can be made configurable
|
||||||
|
rrdService := NewRRDService(log, rrdDir, interfaceName)
|
||||||
|
|
||||||
return &Service{
|
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
|
// ServiceStatus represents a systemd service status
|
||||||
type ServiceStatus struct {
|
type ServiceStatus struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -42,31 +63,37 @@ type ServiceStatus struct {
|
|||||||
|
|
||||||
// GetServiceStatus retrieves the status of a systemd service
|
// GetServiceStatus retrieves the status of a systemd service
|
||||||
func (s *Service) GetServiceStatus(ctx context.Context, serviceName string) (*ServiceStatus, error) {
|
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{
|
status := &ServiceStatus{
|
||||||
Name: serviceName,
|
Name: serviceName,
|
||||||
ActiveState: strings.TrimSpace(lines[0]),
|
|
||||||
SubState: strings.TrimSpace(lines[1]),
|
|
||||||
LoadState: strings.TrimSpace(lines[2]),
|
|
||||||
Description: strings.TrimSpace(lines[3]),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse timestamp if available
|
// Get each property individually to ensure correct parsing
|
||||||
if len(lines) > 4 && lines[4] != "" {
|
properties := map[string]*string{
|
||||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", strings.TrimSpace(lines[4])); err == nil {
|
"ActiveState": &status.ActiveState,
|
||||||
status.Since = t
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,10 +103,15 @@ func (s *Service) GetServiceStatus(ctx context.Context, serviceName string) (*Se
|
|||||||
// ListServices lists all Calypso-related services
|
// ListServices lists all Calypso-related services
|
||||||
func (s *Service) ListServices(ctx context.Context) ([]ServiceStatus, error) {
|
func (s *Service) ListServices(ctx context.Context) ([]ServiceStatus, error) {
|
||||||
services := []string{
|
services := []string{
|
||||||
|
"ssh",
|
||||||
|
"sshd",
|
||||||
|
"smbd",
|
||||||
|
"iscsi-scst",
|
||||||
|
"nfs-server",
|
||||||
|
"nfs",
|
||||||
|
"mhvtl",
|
||||||
"calypso-api",
|
"calypso-api",
|
||||||
"scst",
|
"scst",
|
||||||
"iscsi-scst",
|
|
||||||
"mhvtl",
|
|
||||||
"postgresql",
|
"postgresql",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +167,108 @@ func (s *Service) GetJournalLogs(ctx context.Context, serviceName string, lines
|
|||||||
return logs, nil
|
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
|
// GenerateSupportBundle generates a diagnostic support bundle
|
||||||
func (s *Service) GenerateSupportBundle(ctx context.Context, outputPath string) error {
|
func (s *Service) GenerateSupportBundle(ctx context.Context, outputPath string) error {
|
||||||
// Create bundle directory
|
// Create bundle directory
|
||||||
@@ -314,33 +448,57 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface
|
|||||||
lines = strings.Split(string(output), "\n")
|
lines = strings.Split(string(output), "\n")
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse default route: "default via 10.10.14.1 dev ens18"
|
||||||
if strings.HasPrefix(line, "default via ") {
|
if strings.HasPrefix(line, "default via ") {
|
||||||
// Format: "default via 192.168.1.1 dev ens18"
|
|
||||||
parts := strings.Fields(line)
|
parts := strings.Fields(line)
|
||||||
if len(parts) >= 4 && parts[2] == "dev" {
|
// Find "via" and "dev" in the parts
|
||||||
gateway := parts[1]
|
var gateway string
|
||||||
ifaceName := parts[3]
|
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 {
|
if iface, exists := interfaceMap[ifaceName]; exists {
|
||||||
iface.Gateway = gateway
|
iface.Gateway = gateway
|
||||||
s.logger.Debug("Set gateway for interface", "name", ifaceName, "gateway", gateway)
|
s.logger.Info("Set default gateway for interface", "name", ifaceName, "gateway", gateway)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if strings.Contains(line, " via ") && strings.Contains(line, " dev ") {
|
} else if strings.Contains(line, " via ") && strings.Contains(line, " dev ") {
|
||||||
// Format: "10.10.14.0/24 via 10.10.14.1 dev ens18"
|
// 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)
|
parts := strings.Fields(line)
|
||||||
|
var gateway string
|
||||||
|
var ifaceName string
|
||||||
for i, part := range parts {
|
for i, part := range parts {
|
||||||
if part == "via" && i+1 < len(parts) && i+2 < len(parts) && parts[i+2] == "dev" {
|
if part == "via" && i+1 < len(parts) {
|
||||||
gateway := parts[i+1]
|
gateway = parts[i+1]
|
||||||
ifaceName := parts[i+3]
|
}
|
||||||
if iface, exists := interfaceMap[ifaceName]; exists {
|
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
|
iface.Gateway = gateway
|
||||||
s.logger.Debug("Set gateway for interface", "name", ifaceName, "gateway", gateway)
|
s.logger.Info("Set gateway from network route for interface", "name", ifaceName, "gateway", gateway)
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
s.logger.Warn("Failed to get routes", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get DNS servers from systemd-resolved or /etc/resolv.conf
|
// Get DNS servers from systemd-resolved or /etc/resolv.conf
|
||||||
@@ -437,6 +595,123 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface
|
|||||||
return interfaces, nil
|
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
|
// SaveNTPSettings saves NTP configuration to the OS
|
||||||
func (s *Service) SaveNTPSettings(ctx context.Context, settings NTPSettings) error {
|
func (s *Service) SaveNTPSettings(ctx context.Context, settings NTPSettings) error {
|
||||||
// Set timezone using timedatectl
|
// Set timezone using timedatectl
|
||||||
|
|||||||
@@ -31,6 +31,28 @@ export interface NTPSettings {
|
|||||||
ntp_servers: 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 = {
|
export const systemAPI = {
|
||||||
listNetworkInterfaces: async (): Promise<NetworkInterface[]> => {
|
listNetworkInterfaces: async (): Promise<NetworkInterface[]> => {
|
||||||
const response = await apiClient.get<{ interfaces: NetworkInterface[] | null }>('/system/interfaces')
|
const response = await apiClient.get<{ interfaces: NetworkInterface[] | null }>('/system/interfaces')
|
||||||
@@ -47,5 +69,20 @@ export const systemAPI = {
|
|||||||
saveNTPSettings: async (data: SaveNTPSettingsRequest): Promise<void> => {
|
saveNTPSettings: async (data: SaveNTPSettingsRequest): Promise<void> => {
|
||||||
await apiClient.post('/system/ntp', data)
|
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 || []
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState, useMemo, useEffect } from 'react'
|
|||||||
import apiClient from '@/api/client'
|
import apiClient from '@/api/client'
|
||||||
import { monitoringApi } from '@/api/monitoring'
|
import { monitoringApi } from '@/api/monitoring'
|
||||||
import { storageApi } from '@/api/storage'
|
import { storageApi } from '@/api/storage'
|
||||||
|
import { systemAPI } from '@/api/system'
|
||||||
import { formatBytes } from '@/lib/format'
|
import { formatBytes } from '@/lib/format'
|
||||||
import {
|
import {
|
||||||
Cpu,
|
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() {
|
export default function Dashboard() {
|
||||||
const [activeTab, setActiveTab] = useState<'jobs' | 'logs' | 'alerts'>('jobs')
|
const [activeTab, setActiveTab] = useState<'jobs' | 'logs' | 'alerts'>('jobs')
|
||||||
const [networkDataPoints, setNetworkDataPoints] = useState<Array<{ time: string; inbound: number; outbound: number }>>([])
|
const [networkDataPoints, setNetworkDataPoints] = useState<Array<{ time: string; inbound: number; outbound: number }>>([])
|
||||||
const refreshInterval = 5
|
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({
|
const { data: health } = useQuery({
|
||||||
queryKey: ['health'],
|
queryKey: ['health'],
|
||||||
@@ -143,51 +145,25 @@ export default function Dashboard() {
|
|||||||
return { totalStorage: total, usedStorage: used, storagePercent: percent }
|
return { totalStorage: total, usedStorage: used, storagePercent: percent }
|
||||||
}, [repositories])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
// Generate initial 30 data points
|
if (networkThroughput.length > 0) {
|
||||||
const initialData = []
|
// Take last 30 points
|
||||||
const now = Date.now()
|
const points = networkThroughput.slice(-30).map((point) => ({
|
||||||
for (let i = 29; i >= 0; i--) {
|
time: point.time,
|
||||||
const time = new Date(now - i * 5000)
|
inbound: Math.round(point.inbound),
|
||||||
const minutes = time.getMinutes().toString().padStart(2, '0')
|
outbound: Math.round(point.outbound),
|
||||||
const seconds = time.getSeconds().toString().padStart(2, '0')
|
}))
|
||||||
|
setNetworkDataPoints(points)
|
||||||
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),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
setNetworkDataPoints(initialData)
|
}, [networkThroughput])
|
||||||
|
|
||||||
// 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)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Calculate current and peak throughput
|
// Calculate current and peak throughput
|
||||||
const currentThroughput = useMemo(() => {
|
const currentThroughput = useMemo(() => {
|
||||||
@@ -564,39 +540,59 @@ export default function Dashboard() {
|
|||||||
<h4 className="text-xs uppercase text-text-secondary font-bold tracking-wider">
|
<h4 className="text-xs uppercase text-text-secondary font-bold tracking-wider">
|
||||||
Recent System Events
|
Recent System Events
|
||||||
</h4>
|
</h4>
|
||||||
<button className="text-xs text-primary hover:text-white transition-colors">
|
<div className="flex items-center gap-3">
|
||||||
View All Logs
|
<button
|
||||||
</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>
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar bg-[#111a22]">
|
<div className="flex-1 overflow-y-auto custom-scrollbar bg-[#111a22]">
|
||||||
<table className="w-full text-left border-collapse">
|
{logsLoading ? (
|
||||||
<tbody className="text-sm font-mono divide-y divide-border-dark/50">
|
<div className="flex items-center justify-center py-8">
|
||||||
{MOCK_SYSTEM_LOGS.map((log, idx) => (
|
<span className="text-text-secondary">Loading logs...</span>
|
||||||
<tr key={idx} className="group hover:bg-[#233648] transition-colors">
|
</div>
|
||||||
<td className="px-6 py-2 text-text-secondary w-32 whitespace-nowrap">
|
) : systemLogs.length === 0 ? (
|
||||||
{log.time}
|
<div className="flex items-center justify-center py-8">
|
||||||
</td>
|
<span className="text-text-secondary">No logs available</span>
|
||||||
<td className="px-6 py-2 w-24">
|
</div>
|
||||||
<span
|
) : (
|
||||||
className={
|
<table className="w-full text-left border-collapse">
|
||||||
log.level === 'INFO'
|
<tbody className="text-sm font-mono divide-y divide-border-dark/50">
|
||||||
? 'text-emerald-500'
|
{systemLogs.map((log, idx) => (
|
||||||
: log.level === 'WARN'
|
<tr key={idx} className="group hover:bg-[#233648] transition-colors">
|
||||||
? 'text-yellow-500'
|
<td className="px-6 py-2 text-text-secondary w-32 whitespace-nowrap">
|
||||||
: 'text-red-500'
|
{log.time}
|
||||||
}
|
</td>
|
||||||
>
|
<td className="px-6 py-2 w-24">
|
||||||
{log.level}
|
<span
|
||||||
</span>
|
className={
|
||||||
</td>
|
log.level === 'INFO' || log.level === 'NOTICE' || log.level === 'DEBUG'
|
||||||
<td className="px-6 py-2 w-32 text-white">{log.source}</td>
|
? 'text-emerald-500'
|
||||||
<td className="px-6 py-2 text-text-secondary truncate max-w-lg">
|
: log.level === 'WARN'
|
||||||
{log.message}
|
? 'text-yellow-500'
|
||||||
</td>
|
: 'text-red-500'
|
||||||
</tr>
|
}
|
||||||
))}
|
>
|
||||||
</tbody>
|
{log.level}
|
||||||
</table>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -696,10 +696,15 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
|||||||
iamApi.updateUser(user.id, data),
|
iamApi.updateUser(user.id, data),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
onSuccess()
|
onSuccess()
|
||||||
|
// Invalidate all related queries to refresh counts
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
|
||||||
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
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-user', user.id] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error('Failed to update user:', error)
|
console.error('Failed to update user:', error)
|
||||||
@@ -725,9 +730,11 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
|||||||
},
|
},
|
||||||
onSuccess: async (_, roleName: string) => {
|
onSuccess: async (_, roleName: string) => {
|
||||||
// Don't overwrite state with server data - keep optimistic update
|
// 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-users'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
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
|
// Use functional update to get current state
|
||||||
setUserRoles(current => {
|
setUserRoles(current => {
|
||||||
console.log('assignRoleMutation onSuccess - roleName:', roleName, 'current userRoles:', current)
|
console.log('assignRoleMutation onSuccess - roleName:', roleName, 'current userRoles:', current)
|
||||||
@@ -753,9 +760,11 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
|||||||
},
|
},
|
||||||
onSuccess: async (_, roleName: string) => {
|
onSuccess: async (_, roleName: string) => {
|
||||||
// Don't overwrite state with server data - keep optimistic update
|
// 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-users'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
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)
|
console.log('Role removed successfully:', roleName, 'Current userRoles:', userRoles)
|
||||||
},
|
},
|
||||||
onError: (error: any, _roleName: string, context: any) => {
|
onError: (error: any, _roleName: string, context: any) => {
|
||||||
@@ -785,9 +794,11 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
|||||||
},
|
},
|
||||||
onSuccess: async (_, groupName: string) => {
|
onSuccess: async (_, groupName: string) => {
|
||||||
// Don't overwrite state with server data - keep optimistic update
|
// 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-users'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
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
|
// Use functional update to get current state
|
||||||
setUserGroups(current => {
|
setUserGroups(current => {
|
||||||
console.log('assignGroupMutation onSuccess - groupName:', groupName, 'current userGroups:', current)
|
console.log('assignGroupMutation onSuccess - groupName:', groupName, 'current userGroups:', current)
|
||||||
@@ -813,9 +824,11 @@ function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
|
|||||||
},
|
},
|
||||||
onSuccess: async (_, groupName: string) => {
|
onSuccess: async (_, groupName: string) => {
|
||||||
// Don't overwrite state with server data - keep optimistic update
|
// 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-users'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
|
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)
|
console.log('Group removed successfully:', groupName, 'Current userGroups:', userGroups)
|
||||||
},
|
},
|
||||||
onError: (error: any, _groupName: string, context: any) => {
|
onError: (error: any, _groupName: string, context: any) => {
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ export default function System() {
|
|||||||
const [snmpEnabled, setSnmpEnabled] = useState(false)
|
const [snmpEnabled, setSnmpEnabled] = useState(false)
|
||||||
const [openMenu, setOpenMenu] = useState<string | null>(null)
|
const [openMenu, setOpenMenu] = useState<string | null>(null)
|
||||||
const [editingInterface, setEditingInterface] = useState<NetworkInterface | null>(null)
|
const [editingInterface, setEditingInterface] = useState<NetworkInterface | null>(null)
|
||||||
|
const [viewingInterface, setViewingInterface] = useState<NetworkInterface | null>(null)
|
||||||
const [timezone, setTimezone] = useState('Etc/UTC')
|
const [timezone, setTimezone] = useState('Etc/UTC')
|
||||||
const [ntpServers, setNtpServers] = useState<string[]>(['pool.ntp.org', 'time.google.com'])
|
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 menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -34,6 +37,14 @@ export default function System() {
|
|||||||
refetchInterval: 5000, // Refresh every 5 seconds
|
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
|
// Fetch NTP settings on mount
|
||||||
const { data: ntpSettings } = useQuery({
|
const { data: ntpSettings } = useQuery({
|
||||||
queryKey: ['system', 'ntp'],
|
queryKey: ['system', 'ntp'],
|
||||||
@@ -200,7 +211,7 @@ export default function System() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// TODO: Implement view details
|
setViewingInterface(iface)
|
||||||
setOpenMenu(null)
|
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"
|
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-white hover:bg-border-dark transition-colors"
|
||||||
@@ -245,145 +256,65 @@ export default function System() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 flex flex-col gap-1">
|
<div className="p-4 flex flex-col gap-1">
|
||||||
{/* Service Row */}
|
{servicesLoading ? (
|
||||||
<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 justify-center py-8">
|
||||||
<div className="flex items-center gap-3">
|
<span className="text-text-secondary">Loading services...</span>
|
||||||
<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>
|
|
||||||
</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>
|
// Service configs to display - map backend service names to display configs
|
||||||
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
[
|
||||||
<input
|
{ key: 'ssh', serviceNames: ['ssh', 'sshd'], displayName: 'SSH Service', description: 'Remote command line access', icon: 'terminal' },
|
||||||
defaultChecked
|
{ key: 'smb', serviceNames: ['smbd', 'samba', 'smb'], displayName: 'SMB / CIFS', description: 'Windows file sharing', icon: 'folder_shared' },
|
||||||
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"
|
{ key: 'iscsi', serviceNames: ['iscsi-scst', 'iscsi', 'scst'], displayName: 'iSCSI Target', description: 'Block storage sharing', icon: 'storage' },
|
||||||
id="ssh-toggle"
|
{ key: 'nfs', serviceNames: ['nfs-server', 'nfs', 'nfsd'], displayName: 'NFS Service', description: 'Unix file sharing', icon: 'share' },
|
||||||
name="toggle"
|
{ key: 'vtl', serviceNames: ['mhvtl', 'vtl'], displayName: 'VTL Service', description: 'Virtual tape library emulation', icon: 'album' },
|
||||||
type="checkbox"
|
].map((config) => {
|
||||||
/>
|
const service = services.find(s => {
|
||||||
<label
|
const serviceNameLower = s.name.toLowerCase()
|
||||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
return config.serviceNames.some(name => serviceNameLower.includes(name.toLowerCase()) || name.toLowerCase().includes(serviceNameLower))
|
||||||
htmlFor="ssh-toggle"
|
})
|
||||||
></label>
|
const isActive = service?.active_state === 'active'
|
||||||
</div>
|
const status = isActive ? 'RUNNING' : 'STOPPED'
|
||||||
</div>
|
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'
|
||||||
</div>
|
|
||||||
{/* Service Row */}
|
return (
|
||||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
|
<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="flex items-center gap-3">
|
||||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
<div className="p-2 rounded bg-border-dark/50 text-white">
|
||||||
<span className="material-symbols-outlined text-[20px]">folder_shared</span>
|
<span className="material-symbols-outlined text-[20px]">{config.icon}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-bold text-white">SMB / CIFS</p>
|
<p className="text-sm font-bold text-white">{config.displayName}</p>
|
||||||
<p className="text-xs text-text-secondary">Windows file sharing</p>
|
<p className="text-xs text-text-secondary">{config.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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>
|
<span className={`px-2 py-0.5 rounded text-[10px] font-bold border ${statusColor}`}>{status}</span>
|
||||||
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
<label className="relative inline-block w-10 h-5 mr-2 align-middle select-none cursor-pointer">
|
||||||
<input
|
<input
|
||||||
defaultChecked
|
checked={isActive}
|
||||||
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"
|
onChange={() => {
|
||||||
id="smb-toggle"
|
if (service) {
|
||||||
name="toggle"
|
systemAPI.restartService(service.name).then(() => {
|
||||||
type="checkbox"
|
queryClient.invalidateQueries({ queryKey: ['system', 'services'] })
|
||||||
/>
|
}).catch((err) => {
|
||||||
<label
|
alert(`Failed to ${isActive ? 'stop' : 'start'} service: ${err.message || 'Unknown error'}`)
|
||||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
})
|
||||||
htmlFor="smb-toggle"
|
}
|
||||||
></label>
|
}}
|
||||||
</div>
|
className="sr-only peer"
|
||||||
</div>
|
id={`${config.key}-toggle`}
|
||||||
</div>
|
name="toggle"
|
||||||
{/* Service Row */}
|
type="checkbox"
|
||||||
<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">
|
<span className="absolute inset-0 rounded-full bg-border-dark transition-colors duration-300 peer-checked:bg-primary/20"></span>
|
||||||
<div className="p-2 rounded bg-border-dark/50 text-white">
|
<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>
|
||||||
<span className="material-symbols-outlined text-[20px]">storage</span>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -440,11 +371,60 @@ export default function System() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="block text-xs font-medium text-text-secondary uppercase">NTP Servers</label>
|
<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
|
<span className="material-symbols-outlined text-[14px]">add</span> Add Server
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
{ntpServers.map((server, index) => (
|
{ntpServers.map((server, index) => (
|
||||||
<div key={index} className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-border-dark">
|
<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="flex items-center gap-3">
|
||||||
@@ -484,20 +464,18 @@ export default function System() {
|
|||||||
<h3 className="text-sm font-bold text-white">SNMP Monitoring</h3>
|
<h3 className="text-sm font-bold text-white">SNMP Monitoring</h3>
|
||||||
<p className="text-xs text-text-secondary">Enable Simple Network Management Protocol</p>
|
<p className="text-xs text-text-secondary">Enable Simple Network Management Protocol</p>
|
||||||
</div>
|
</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
|
<input
|
||||||
checked={snmpEnabled}
|
checked={snmpEnabled}
|
||||||
onChange={(e) => setSnmpEnabled(e.target.checked)}
|
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"
|
id="snmp-toggle"
|
||||||
name="toggle"
|
name="toggle"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<label
|
<span className="absolute inset-0 rounded-full bg-border-dark transition-colors duration-300 peer-checked:bg-primary/20"></span>
|
||||||
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
|
<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>
|
||||||
htmlFor="snmp-toggle"
|
</label>
|
||||||
></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={`grid grid-cols-1 gap-4 transition-opacity ${snmpEnabled ? 'opacity-100' : 'opacity-50 pointer-events-none'}`}>
|
<div className={`grid grid-cols-1 gap-4 transition-opacity ${snmpEnabled ? 'opacity-100' : 'opacity-50 pointer-events-none'}`}>
|
||||||
<div>
|
<div>
|
||||||
@@ -550,6 +528,14 @@ export default function System() {
|
|||||||
onClose={() => setEditingInterface(null)}
|
onClose={() => setEditingInterface(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* View Details Modal */}
|
||||||
|
{viewingInterface && (
|
||||||
|
<ViewDetailsModal
|
||||||
|
interface={viewingInterface}
|
||||||
|
onClose={() => setViewingInterface(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -727,3 +713,128 @@ function EditConnectionModal({ interface: iface, onClose }: EditConnectionModalP
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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