diff --git a/backend/bin/calypso-api b/backend/bin/calypso-api index 4a4737a..ffe8999 100755 Binary files a/backend/bin/calypso-api and b/backend/bin/calypso-api differ diff --git a/backend/internal/common/router/router.go b/backend/internal/common/router/router.go index fd31ec7..9a80c0f 100644 --- a/backend/internal/common/router/router.go +++ b/backend/internal/common/router/router.go @@ -260,7 +260,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 +279,11 @@ 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) } diff --git a/backend/internal/iam/group.go b/backend/internal/iam/group.go index b64243b..4f4acd4 100644 --- a/backend/internal/iam/group.go +++ b/backend/internal/iam/group.go @@ -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() } diff --git a/backend/internal/iam/handler.go b/backend/internal/iam/handler.go index 7ca67dc..4360d93 100644 --- a/backend/internal/iam/handler.go +++ b/backend/internal/iam/handler.go @@ -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 diff --git a/backend/internal/iam/user.go b/backend/internal/iam/user.go index 8bea90e..2c343fc 100644 --- a/backend/internal/iam/user.go +++ b/backend/internal/iam/user.go @@ -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 diff --git a/backend/internal/system/handler.go b/backend/internal/system/handler.go index edb84f4..41132ef 100644 --- a/backend/internal/system/handler.go +++ b/backend/internal/system/handler.go @@ -173,3 +173,63 @@ func (h *Handler) GetNTPSettings(c *gin.Context) { 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}) +} diff --git a/backend/internal/system/rrd.go b/backend/internal/system/rrd.go new file mode 100644 index 0000000..94d228b --- /dev/null +++ b/backend/internal/system/rrd.go @@ -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 +} diff --git a/backend/internal/system/service.go b/backend/internal/system/service.go index 95b2757..2ee6c9a 100644 --- a/backend/internal/system/service.go +++ b/backend/internal/system/service.go @@ -20,16 +20,37 @@ type NTPSettings struct { // Service handles system management operations type Service struct { - logger *logger.Logger + logger *logger.Logger + rrdService *RRDService } // NewService creates a new system 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{ - 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"` @@ -42,31 +63,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 + } } } @@ -76,10 +103,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", } @@ -135,6 +167,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 @@ -314,33 +448,57 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface 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 ") { - // Format: "default via 192.168.1.1 dev ens18" parts := strings.Fields(line) - if len(parts) >= 4 && parts[2] == "dev" { - gateway := parts[1] - ifaceName := parts[3] + // 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.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 ") { - // 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) + var gateway string + var ifaceName string for i, part := range parts { - if part == "via" && i+1 < len(parts) && i+2 < len(parts) && parts[i+2] == "dev" { - gateway := parts[i+1] - ifaceName := parts[i+3] - if iface, exists := interfaceMap[ifaceName]; exists { + 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.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 @@ -437,6 +595,123 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface 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 diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index 2abc97d..8a0c817 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -31,6 +31,28 @@ export interface NTPSettings { 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 = { listNetworkInterfaces: async (): Promise => { const response = await apiClient.get<{ interfaces: NetworkInterface[] | null }>('/system/interfaces') @@ -47,5 +69,20 @@ export const systemAPI = { saveNTPSettings: async (data: SaveNTPSettingsRequest): Promise => { await apiClient.post('/system/ntp', data) }, + listServices: async (): Promise => { + const response = await apiClient.get<{ services: ServiceStatus[] }>('/system/services') + return response.data.services || [] + }, + restartService: async (name: string): Promise => { + await apiClient.post(`/system/services/${name}/restart`) + }, + getSystemLogs: async (limit: number = 30): Promise => { + const response = await apiClient.get<{ logs: SystemLogEntry[] }>(`/system/logs?limit=${limit}`) + return response.data.logs || [] + }, + getNetworkThroughput: async (duration: string = '5m'): Promise => { + const response = await apiClient.get<{ data: NetworkDataPoint[] }>(`/system/network/throughput?duration=${duration}`) + return response.data.data || [] + }, } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 9db78a2..987bd21 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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>([]) 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() {

Recent System Events

- +
+ + +
- - - {MOCK_SYSTEM_LOGS.map((log, idx) => ( - - - - - - - ))} - -
- {log.time} - - - {log.level} - - {log.source} - {log.message} -
+ {logsLoading ? ( +
+ Loading logs... +
+ ) : systemLogs.length === 0 ? ( +
+ No logs available +
+ ) : ( + + + {systemLogs.map((log, idx) => ( + + + + + + + ))} + +
+ {log.time} + + + {log.level} + + {log.source} + {log.message} +
+ )}
)} diff --git a/frontend/src/pages/IAM.tsx b/frontend/src/pages/IAM.tsx index c287ba5..e474ee4 100644 --- a/frontend/src/pages/IAM.tsx +++ b/frontend/src/pages/IAM.tsx @@ -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) => { diff --git a/frontend/src/pages/System.tsx b/frontend/src/pages/System.tsx index aaddd4d..f568200 100644 --- a/frontend/src/pages/System.tsx +++ b/frontend/src/pages/System.tsx @@ -7,8 +7,11 @@ export default function System() { const [snmpEnabled, setSnmpEnabled] = useState(false) const [openMenu, setOpenMenu] = useState(null) const [editingInterface, setEditingInterface] = useState(null) + const [viewingInterface, setViewingInterface] = useState(null) const [timezone, setTimezone] = useState('Etc/UTC') const [ntpServers, setNtpServers] = useState(['pool.ntp.org', 'time.google.com']) + const [showAddNtpServer, setShowAddNtpServer] = useState(false) + const [newNtpServer, setNewNtpServer] = useState('') const menuRef = useRef(null) const queryClient = useQueryClient() @@ -34,6 +37,14 @@ 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'], @@ -200,7 +211,7 @@ export default function System() {
+ {showAddNtpServer && ( +
+ 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 + /> + + +
+ )} {ntpServers.map((server, index) => (
@@ -484,20 +464,18 @@ export default function System() {

SNMP Monitoring

Enable Simple Network Management Protocol

-
+
+ + +
@@ -550,6 +528,14 @@ export default function System() { onClose={() => setEditingInterface(null)} /> )} + + {/* View Details Modal */} + {viewingInterface && ( + setViewingInterface(null)} + /> + )}
) } @@ -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 ( +
+
+
+
+ info +

Interface Details - {iface.name}

+
+ +
+ +
+
+ {/* Status */} +
+
+
+ Status +
+ + {iface.status} + +
+ + {/* Network Configuration Grid */} +
+ {/* IP Address */} +
+ +

{iface.ip_address || 'Not configured'}

+
+ + {/* Subnet */} +
+ +

/{iface.subnet || 'N/A'}

+
+ + {/* Gateway */} +
+ +

{iface.gateway || 'Not configured'}

+
+ + {/* Speed */} +
+ +

{iface.speed || 'Unknown'}

+
+
+ + {/* DNS Servers */} +
+ +
+ {iface.dns1 ? ( +
+ Primary: + {iface.dns1} +
+ ) : ( +

Primary DNS: Not configured

+ )} + {iface.dns2 ? ( +
+ Secondary: + {iface.dns2} +
+ ) : ( +

Secondary DNS: Not configured

+ )} +
+
+ + {/* Interface Role */} + {iface.role && ( +
+ + + {iface.role} + +
+ )} + + {/* Full Network Address */} + {iface.ip_address && iface.subnet && ( +
+ +

{iface.ip_address}/{iface.subnet}

+
+ )} +
+ + {/* Close Button */} +
+ +
+
+
+
+ ) +} +