fix mostly bugs on system management, and user roles and group assignment

This commit is contained in:
Warp Agent
2025-12-30 01:49:19 +07:00
parent cb923704db
commit ebaf718424
12 changed files with 1178 additions and 282 deletions

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -21,15 +21,36 @@ 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,33 +63,39 @@ 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,
"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 status.Since = t
} }
} }
}
return status, nil return status, nil
} }
@@ -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,34 +448,58 @@ 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 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, 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
// Try systemd-resolved first // Try systemd-resolved first
@@ -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

View File

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

View File

@@ -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,18 +47,19 @@ 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'],
queryFn: async () => { queryFn: async () => {
@@ -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,14 +540,33 @@ 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>
<div className="flex items-center gap-3">
<button
onClick={() => refetchLogs()}
disabled={logsLoading}
className="text-xs text-primary hover:text-white transition-colors flex items-center gap-1 disabled:opacity-50"
>
<RefreshCw size={14} className={logsLoading ? 'animate-spin' : ''} />
Refresh
</button>
<button className="text-xs text-primary hover:text-white transition-colors"> <button className="text-xs text-primary hover:text-white transition-colors">
View All Logs View All Logs
</button> </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]">
{logsLoading ? (
<div className="flex items-center justify-center py-8">
<span className="text-text-secondary">Loading logs...</span>
</div>
) : systemLogs.length === 0 ? (
<div className="flex items-center justify-center py-8">
<span className="text-text-secondary">No logs available</span>
</div>
) : (
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
<tbody className="text-sm font-mono divide-y divide-border-dark/50"> <tbody className="text-sm font-mono divide-y divide-border-dark/50">
{MOCK_SYSTEM_LOGS.map((log, idx) => ( {systemLogs.map((log, idx) => (
<tr key={idx} className="group hover:bg-[#233648] transition-colors"> <tr key={idx} className="group hover:bg-[#233648] transition-colors">
<td className="px-6 py-2 text-text-secondary w-32 whitespace-nowrap"> <td className="px-6 py-2 text-text-secondary w-32 whitespace-nowrap">
{log.time} {log.time}
@@ -579,7 +574,7 @@ export default function Dashboard() {
<td className="px-6 py-2 w-24"> <td className="px-6 py-2 w-24">
<span <span
className={ className={
log.level === 'INFO' log.level === 'INFO' || log.level === 'NOTICE' || log.level === 'DEBUG'
? 'text-emerald-500' ? 'text-emerald-500'
: log.level === 'WARN' : log.level === 'WARN'
? 'text-yellow-500' ? 'text-yellow-500'
@@ -597,6 +592,7 @@ export default function Dashboard() {
))} ))}
</tbody> </tbody>
</table> </table>
)}
</div> </div>
</> </>
)} )}

View File

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

View File

@@ -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">
<span className="text-text-secondary">Loading services...</span>
</div>
) : (
// Service configs to display - map backend service names to display configs
[
{ key: 'ssh', serviceNames: ['ssh', 'sshd'], displayName: 'SSH Service', description: 'Remote command line access', icon: 'terminal' },
{ key: 'smb', serviceNames: ['smbd', 'samba', 'smb'], displayName: 'SMB / CIFS', description: 'Windows file sharing', icon: 'folder_shared' },
{ key: 'iscsi', serviceNames: ['iscsi-scst', 'iscsi', 'scst'], displayName: 'iSCSI Target', description: 'Block storage sharing', icon: 'storage' },
{ key: 'nfs', serviceNames: ['nfs-server', 'nfs', 'nfsd'], displayName: 'NFS Service', description: 'Unix file sharing', icon: 'share' },
{ key: 'vtl', serviceNames: ['mhvtl', 'vtl'], displayName: 'VTL Service', description: 'Virtual tape library emulation', icon: 'album' },
].map((config) => {
const service = services.find(s => {
const serviceNameLower = s.name.toLowerCase()
return config.serviceNames.some(name => serviceNameLower.includes(name.toLowerCase()) || name.toLowerCase().includes(serviceNameLower))
})
const isActive = service?.active_state === 'active'
const status = isActive ? 'RUNNING' : 'STOPPED'
const statusColor = isActive ? 'bg-green-500/20 text-green-500 border-green-500/20' : 'bg-yellow-500/20 text-yellow-500 border-yellow-500/20'
return (
<div key={config.key} className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
<div className="flex items-center gap-3"> <div className="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]">terminal</span> <span className="material-symbols-outlined text-[20px]">{config.icon}</span>
</div> </div>
<div> <div>
<p className="text-sm font-bold text-white">SSH Service</p> <p className="text-sm font-bold text-white">{config.displayName}</p>
<p className="text-xs text-text-secondary">Remote command line access</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="ssh-toggle" if (service) {
systemAPI.restartService(service.name).then(() => {
queryClient.invalidateQueries({ queryKey: ['system', 'services'] })
}).catch((err) => {
alert(`Failed to ${isActive ? 'stop' : 'start'} service: ${err.message || 'Unknown error'}`)
})
}
}}
className="sr-only peer"
id={`${config.key}-toggle`}
name="toggle" 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="ssh-toggle" </label>
></label>
</div>
</div>
</div>
{/* Service Row */}
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-border-dark/50 text-white">
<span className="material-symbols-outlined text-[20px]">folder_shared</span>
</div>
<div>
<p className="text-sm font-bold text-white">SMB / CIFS</p>
<p className="text-xs text-text-secondary">Windows file sharing</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input
defaultChecked
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
id="smb-toggle"
name="toggle"
type="checkbox"
/>
<label
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
htmlFor="smb-toggle"
></label>
</div>
</div>
</div>
{/* Service Row */}
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-border-dark/50 text-white">
<span className="material-symbols-outlined text-[20px]">storage</span>
</div>
<div>
<p className="text-sm font-bold text-white">iSCSI Target</p>
<p className="text-xs text-text-secondary">Block storage sharing</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-yellow-500/20 text-yellow-500 border border-yellow-500/20">STOPPED</span>
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
id="iscsi-toggle"
name="toggle"
type="checkbox"
/>
<label
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
htmlFor="iscsi-toggle"
></label>
</div>
</div>
</div>
{/* Service Row */}
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-border-dark/50 text-white">
<span className="material-symbols-outlined text-[20px]">share</span>
</div>
<div>
<p className="text-sm font-bold text-white">NFS Service</p>
<p className="text-xs text-text-secondary">Unix file sharing</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input
defaultChecked
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
id="nfs-toggle"
name="toggle"
type="checkbox"
/>
<label
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
htmlFor="nfs-toggle"
></label>
</div>
</div>
</div>
{/* Service Row - VTL (MHVTL) */}
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-transparent hover:border-border-dark transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-border-dark/50 text-white">
<span className="material-symbols-outlined text-[20px]">album</span>
</div>
<div>
<p className="text-sm font-bold text-white">VTL Service</p>
<p className="text-xs text-text-secondary">Virtual tape library emulation</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-green-500/20 text-green-500 border border-green-500/20">RUNNING</span>
<div className="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input
defaultChecked
className="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-primary transition-all duration-300"
id="mhvtl-toggle"
name="toggle"
type="checkbox"
/>
<label
className="toggle-label block overflow-hidden h-5 rounded-full bg-border-dark cursor-pointer transition-colors duration-300"
htmlFor="mhvtl-toggle"
></label>
</div>
</div> </div>
</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>
)
}