fix network interface information fetch from OS

This commit is contained in:
Warp Agent
2025-12-29 20:43:34 +07:00
parent 5fdb56e498
commit cb923704db
6 changed files with 595 additions and 22 deletions

Binary file not shown.

View File

@@ -270,6 +270,8 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
systemGroup.GET("/services/:name/logs", systemHandler.GetServiceLogs)
systemGroup.POST("/support-bundle", systemHandler.GenerateSupportBundle)
systemGroup.GET("/interfaces", systemHandler.ListNetworkInterfaces)
systemGroup.GET("/ntp", systemHandler.GetNTPSettings)
systemGroup.POST("/ntp", systemHandler.SaveNTPSettings)
}
// IAM routes - GetUser can be accessed by user viewing own profile or admin

View File

@@ -131,3 +131,45 @@ func (h *Handler) ListNetworkInterfaces(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"interfaces": interfaces})
}
// SaveNTPSettings saves NTP configuration to the OS
func (h *Handler) SaveNTPSettings(c *gin.Context) {
var settings NTPSettings
if err := c.ShouldBindJSON(&settings); err != nil {
h.logger.Error("Invalid request body", "error", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Validate timezone
if settings.Timezone == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "timezone is required"})
return
}
// Validate NTP servers
if len(settings.NTPServers) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "at least one NTP server is required"})
return
}
if err := h.service.SaveNTPSettings(c.Request.Context(), settings); err != nil {
h.logger.Error("Failed to save NTP settings", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "NTP settings saved successfully"})
}
// GetNTPSettings retrieves current NTP configuration
func (h *Handler) GetNTPSettings(c *gin.Context) {
settings, err := h.service.GetNTPSettings(c.Request.Context())
if err != nil {
h.logger.Error("Failed to get NTP settings", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get NTP settings"})
return
}
c.JSON(http.StatusOK, gin.H{"settings": settings})
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"
@@ -11,6 +12,12 @@ import (
"github.com/atlasos/calypso/internal/common/logger"
)
// NTPSettings represents NTP configuration
type NTPSettings struct {
Timezone string `json:"timezone"`
NTPServers []string `json:"ntp_servers"`
}
// Service handles system management operations
type Service struct {
logger *logger.Logger
@@ -183,6 +190,9 @@ type NetworkInterface struct {
Status string `json:"status"` // "Connected" or "Down"
Speed string `json:"speed"` // e.g., "10 Gbps", "1 Gbps"
Role string `json:"role"` // "Management", "ISCSI", or empty
Gateway string `json:"gateway,omitempty"`
DNS1 string `json:"dns1,omitempty"`
DNS2 string `json:"dns2,omitempty"`
}
// ListNetworkInterfaces lists all network interfaces
@@ -297,6 +307,79 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface
}
}
// Get default gateway for each interface
cmd = exec.CommandContext(ctx, "ip", "route", "show")
output, err = cmd.Output()
if err == nil {
lines = strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if 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]
if iface, exists := interfaceMap[ifaceName]; exists {
iface.Gateway = gateway
s.logger.Debug("Set 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"
parts := strings.Fields(line)
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 {
iface.Gateway = gateway
s.logger.Debug("Set gateway for interface", "name", ifaceName, "gateway", gateway)
}
break
}
}
}
}
}
// Get DNS servers from systemd-resolved or /etc/resolv.conf
// Try systemd-resolved first
cmd = exec.CommandContext(ctx, "systemd-resolve", "--status")
output, err = cmd.Output()
dnsServers := []string{}
if err == nil {
// Parse DNS from systemd-resolve output
lines = strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "DNS Servers:") {
// Format: "DNS Servers: 8.8.8.8 8.8.4.4"
parts := strings.Fields(line)
if len(parts) >= 3 {
dnsServers = parts[2:]
}
break
}
}
} else {
// Fallback to /etc/resolv.conf
data, err := os.ReadFile("/etc/resolv.conf")
if err == nil {
lines = strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "nameserver ") {
dns := strings.TrimPrefix(line, "nameserver ")
dns = strings.TrimSpace(dns)
if dns != "" {
dnsServers = append(dnsServers, dns)
}
}
}
}
}
// Convert map to slice
var interfaces []NetworkInterface
s.logger.Debug("Converting interface map to slice", "map_size", len(interfaceMap))
@@ -319,6 +402,14 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface
}
}
// Set DNS servers (use first two if available)
if len(dnsServers) > 0 {
iface.DNS1 = dnsServers[0]
}
if len(dnsServers) > 1 {
iface.DNS2 = dnsServers[1]
}
// Determine role based on interface name or IP (simple heuristic)
// You can enhance this with configuration file or database lookup
if strings.Contains(iface.Name, "eth") || strings.Contains(iface.Name, "ens") {
@@ -345,3 +436,110 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface
s.logger.Info("Listed network interfaces", "count", len(interfaces))
return interfaces, nil
}
// SaveNTPSettings saves NTP configuration to the OS
func (s *Service) SaveNTPSettings(ctx context.Context, settings NTPSettings) error {
// Set timezone using timedatectl
if settings.Timezone != "" {
cmd := exec.CommandContext(ctx, "timedatectl", "set-timezone", settings.Timezone)
output, err := cmd.CombinedOutput()
if err != nil {
s.logger.Error("Failed to set timezone", "timezone", settings.Timezone, "error", err, "output", string(output))
return fmt.Errorf("failed to set timezone: %w", err)
}
s.logger.Info("Timezone set", "timezone", settings.Timezone)
}
// Configure NTP servers in systemd-timesyncd
if len(settings.NTPServers) > 0 {
configPath := "/etc/systemd/timesyncd.conf"
// Build config content
configContent := "[Time]\n"
configContent += "NTP="
for i, server := range settings.NTPServers {
if i > 0 {
configContent += " "
}
configContent += server
}
configContent += "\n"
// Write to temporary file first, then move to final location (requires root)
tmpPath := "/tmp/timesyncd.conf." + fmt.Sprintf("%d", time.Now().Unix())
if err := os.WriteFile(tmpPath, []byte(configContent), 0644); err != nil {
s.logger.Error("Failed to write temporary NTP config", "error", err)
return fmt.Errorf("failed to write temporary NTP configuration: %w", err)
}
// Move to final location using sudo (requires root privileges)
cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("mv %s %s", tmpPath, configPath))
output, err := cmd.CombinedOutput()
if err != nil {
s.logger.Error("Failed to move NTP config", "error", err, "output", string(output))
os.Remove(tmpPath) // Clean up temp file
return fmt.Errorf("failed to move NTP configuration: %w", err)
}
// Restart systemd-timesyncd to apply changes
cmd = exec.CommandContext(ctx, "systemctl", "restart", "systemd-timesyncd")
output, err = cmd.CombinedOutput()
if err != nil {
s.logger.Error("Failed to restart systemd-timesyncd", "error", err, "output", string(output))
return fmt.Errorf("failed to restart systemd-timesyncd: %w", err)
}
s.logger.Info("NTP servers configured", "servers", settings.NTPServers)
}
return nil
}
// GetNTPSettings retrieves current NTP configuration from the OS
func (s *Service) GetNTPSettings(ctx context.Context) (*NTPSettings, error) {
settings := &NTPSettings{
NTPServers: []string{},
}
// Get current timezone using timedatectl
cmd := exec.CommandContext(ctx, "timedatectl", "show", "--property=Timezone", "--value")
output, err := cmd.Output()
if err != nil {
s.logger.Warn("Failed to get timezone", "error", err)
settings.Timezone = "Etc/UTC" // Default fallback
} else {
settings.Timezone = strings.TrimSpace(string(output))
if settings.Timezone == "" {
settings.Timezone = "Etc/UTC"
}
}
// Read NTP servers from systemd-timesyncd config
configPath := "/etc/systemd/timesyncd.conf"
data, err := os.ReadFile(configPath)
if err != nil {
s.logger.Warn("Failed to read NTP config", "error", err)
// Default NTP servers if config file doesn't exist
settings.NTPServers = []string{"pool.ntp.org", "time.google.com"}
} else {
// Parse NTP servers from config file
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "NTP=") {
ntpLine := strings.TrimPrefix(line, "NTP=")
if ntpLine != "" {
servers := strings.Fields(ntpLine)
settings.NTPServers = servers
break
}
}
}
// If no NTP servers found in config, use defaults
if len(settings.NTPServers) == 0 {
settings.NTPServers = []string{"pool.ntp.org", "time.google.com"}
}
}
return settings, nil
}

View File

@@ -7,6 +7,28 @@ export interface NetworkInterface {
status: string // "Connected" or "Down"
speed: string // e.g., "10 Gbps", "1 Gbps"
role: string // "Management", "ISCSI", or empty
gateway?: string
dns1?: string
dns2?: string
}
export interface UpdateNetworkInterfaceRequest {
ip_address: string
subnet: string
gateway?: string
dns1?: string
dns2?: string
role?: string
}
export interface SaveNTPSettingsRequest {
timezone: string
ntp_servers: string[]
}
export interface NTPSettings {
timezone: string
ntp_servers: string[]
}
export const systemAPI = {
@@ -14,5 +36,16 @@ export const systemAPI = {
const response = await apiClient.get<{ interfaces: NetworkInterface[] | null }>('/system/interfaces')
return response.data.interfaces || []
},
updateNetworkInterface: async (name: string, data: UpdateNetworkInterfaceRequest): Promise<NetworkInterface> => {
const response = await apiClient.put<{ interface: NetworkInterface }>(`/system/interfaces/${name}`, data)
return response.data.interface
},
getNTPSettings: async (): Promise<NTPSettings> => {
const response = await apiClient.get<{ settings: NTPSettings }>('/system/ntp')
return response.data.settings
},
saveNTPSettings: async (data: SaveNTPSettingsRequest): Promise<void> => {
await apiClient.post('/system/ntp', data)
},
}

View File

@@ -1,10 +1,31 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { systemAPI, NetworkInterface } from '@/api/system'
export default function System() {
const [snmpEnabled, setSnmpEnabled] = useState(false)
const [openMenu, setOpenMenu] = useState<string | null>(null)
const [editingInterface, setEditingInterface] = useState<NetworkInterface | null>(null)
const [timezone, setTimezone] = useState('Etc/UTC')
const [ntpServers, setNtpServers] = useState<string[]>(['pool.ntp.org', 'time.google.com'])
const menuRef = useRef<HTMLDivElement>(null)
const queryClient = useQueryClient()
// Save NTP settings mutation
const saveNTPSettingsMutation = useMutation({
mutationFn: (data: { timezone: string; ntp_servers: string[] }) => systemAPI.saveNTPSettings(data),
onSuccess: () => {
// Refetch NTP settings to get the updated values
queryClient.invalidateQueries({ queryKey: ['system', 'ntp'] })
// Show success message (you can add a toast notification here)
alert('NTP settings saved successfully!')
},
onError: (error: any) => {
alert(`Failed to save NTP settings: ${error.message || 'Unknown error'}`)
},
})
// Fetch network interfaces
const { data: interfaces = [], isLoading: interfacesLoading } = useQuery({
@@ -13,6 +34,31 @@ export default function System() {
refetchInterval: 5000, // Refresh every 5 seconds
})
// Fetch NTP settings on mount
const { data: ntpSettings } = useQuery({
queryKey: ['system', 'ntp'],
queryFn: () => systemAPI.getNTPSettings(),
})
// Update state when NTP settings are loaded
useEffect(() => {
if (ntpSettings) {
setTimezone(ntpSettings.timezone)
setNtpServers(ntpSettings.ntp_servers)
}
}, [ntpSettings])
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpenMenu(null)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
return (
<div className="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
{/* Top Navigation */}
@@ -133,9 +179,51 @@ export default function System() {
</div>
)}
</div>
<button className="h-8 w-8 rounded-full hover:bg-border-dark flex items-center justify-center text-text-secondary hover:text-white transition-colors">
<span className="material-symbols-outlined">more_vert</span>
</button>
<div className="relative" ref={menuRef}>
<button
onClick={() => setOpenMenu(openMenu === iface.name ? null : iface.name)}
className="h-8 w-8 rounded-full hover:bg-border-dark flex items-center justify-center text-text-secondary hover:text-white transition-colors"
>
<span className="material-symbols-outlined">more_vert</span>
</button>
{openMenu === iface.name && (
<div className="absolute right-0 mt-1 w-48 rounded-lg border border-border-dark bg-card-dark shadow-lg z-50">
<button
onClick={() => {
setEditingInterface(iface)
setOpenMenu(null)
}}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-white hover:bg-border-dark transition-colors first:rounded-t-lg"
>
<span className="material-symbols-outlined text-[18px]">edit</span>
<span>Edit Connection</span>
</button>
<button
onClick={() => {
// TODO: Implement view details
setOpenMenu(null)
}}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-white hover:bg-border-dark transition-colors"
>
<span className="material-symbols-outlined text-[18px]">info</span>
<span>View Details</span>
</button>
<div className="border-t border-border-dark"></div>
<button
onClick={() => {
// TODO: Implement disable/enable
setOpenMenu(null)
}}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-white hover:bg-border-dark transition-colors last:rounded-b-lg"
>
<span className="material-symbols-outlined text-[18px]">
{isConnected ? 'toggle_on' : 'toggle_off'}
</span>
<span>{isConnected ? 'Disable' : 'Enable'}</span>
</button>
</div>
)}
</div>
</div>
</div>
)
@@ -306,18 +394,42 @@ export default function System() {
<span className="material-symbols-outlined text-primary">schedule</span>
<h2 className="text-lg font-bold text-white">Date & Time</h2>
</div>
<span className="text-xs font-mono text-text-secondary bg-border-dark px-2 py-1 rounded">UTC</span>
<button
onClick={() => {
saveNTPSettingsMutation.mutate({
timezone,
ntp_servers: ntpServers,
})
}}
disabled={saveNTPSettingsMutation.isPending}
className="flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-bold text-white hover:bg-blue-600 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[16px]">save</span>
{saveNTPSettingsMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
<div className="p-6 flex flex-col gap-6">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">System Timezone</label>
<div className="relative">
<select className="block w-full rounded-lg border-border-dark bg-[#111a22] py-2.5 pl-3 pr-10 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary appearance-none">
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="block w-full rounded-lg border-border-dark bg-[#111a22] py-2.5 pl-3 pr-10 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary appearance-none"
>
<option>Etc/UTC</option>
<option>America/New_York</option>
<option>Europe/London</option>
<option>Asia/Jakarta</option>
<option>Asia/Singapore</option>
<option>Asia/Bangkok</option>
<option>Asia/Manila</option>
<option>Asia/Tokyo</option>
<option>Asia/Shanghai</option>
<option>Asia/Hong_Kong</option>
<option>Europe/London</option>
<option>Europe/Paris</option>
<option>America/New_York</option>
<option>America/Los_Angeles</option>
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-white">
<span className="material-symbols-outlined text-sm">expand_more</span>
@@ -333,20 +445,25 @@ export default function System() {
</button>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-border-dark">
<div className="flex items-center gap-3">
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse"></div>
<span className="text-sm font-mono text-white">pool.ntp.org</span>
{ntpServers.map((server, index) => (
<div key={index} className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-border-dark">
<div className="flex items-center gap-3">
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse"></div>
<span className="text-sm font-mono text-white">{server}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-secondary">Stratum 2 12ms</span>
<button
onClick={() => {
setNtpServers(ntpServers.filter((_, i) => i !== index))
}}
className="text-red-500 hover:text-red-400"
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
</div>
</div>
<span className="text-xs text-text-secondary">Stratum 2 12ms</span>
</div>
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-border-dark">
<div className="flex items-center gap-3">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<span className="text-sm font-mono text-white">time.google.com</span>
</div>
<span className="text-xs text-text-secondary">Stratum 1 45ms</span>
</div>
))}
</div>
</div>
</div>
@@ -425,6 +542,187 @@ export default function System() {
<div className="h-10"></div>
</div>
</div>
{/* Edit Connection Modal */}
{editingInterface && (
<EditConnectionModal
interface={editingInterface}
onClose={() => setEditingInterface(null)}
/>
)}
</div>
)
}
// Edit Connection Modal Component
interface EditConnectionModalProps {
interface: NetworkInterface
onClose: () => void
}
function EditConnectionModal({ interface: iface, onClose }: EditConnectionModalProps) {
const queryClient = useQueryClient()
const [formData, setFormData] = useState({
ip_address: iface.ip_address || '',
subnet: iface.subnet || '24',
gateway: iface.gateway || '',
dns1: iface.dns1 || '',
dns2: iface.dns2 || '',
role: iface.role || '',
})
const updateMutation = useMutation({
mutationFn: (data: { ip_address: string; subnet: string; gateway?: string; dns1?: string; dns2?: string; role?: string }) =>
systemAPI.updateNetworkInterface(iface.name, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['system', 'interfaces'] })
onClose()
},
onError: (error: any) => {
alert(`Failed to update interface: ${error.response?.data?.error || error.message}`)
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
updateMutation.mutate({
ip_address: formData.ip_address,
subnet: formData.subnet,
gateway: formData.gateway || undefined,
dns1: formData.dns1 || undefined,
dns2: formData.dns2 || undefined,
role: formData.role || undefined,
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-xl border border-border-dark bg-card-dark shadow-xl">
<div className="flex items-center justify-between border-b border-border-dark px-6 py-4">
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-primary">settings_ethernet</span>
<h2 className="text-lg font-bold text-white">Edit Connection - {iface.name}</h2>
</div>
<button
onClick={onClose}
className="h-8 w-8 rounded-full hover:bg-border-dark flex items-center justify-center text-text-secondary hover:text-white transition-colors"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
<form onSubmit={handleSubmit} className="p-6">
<div className="space-y-4">
{/* IP Address */}
<div>
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">
IP Address
</label>
<input
type="text"
value={formData.ip_address}
onChange={(e) => setFormData({ ...formData, ip_address: e.target.value })}
className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
placeholder="192.168.1.100"
required
/>
</div>
{/* Subnet Mask */}
<div>
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">
Subnet Mask (CIDR)
</label>
<input
type="text"
value={formData.subnet}
onChange={(e) => setFormData({ ...formData, subnet: e.target.value })}
className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
placeholder="24"
required
/>
<p className="mt-1 text-xs text-text-secondary">Enter CIDR notation (e.g., 24 for 255.255.255.0)</p>
</div>
{/* Gateway */}
<div>
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">
Default Gateway
</label>
<input
type="text"
value={formData.gateway}
onChange={(e) => setFormData({ ...formData, gateway: e.target.value })}
className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
placeholder="192.168.1.1"
/>
</div>
{/* DNS Servers */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">
Primary DNS
</label>
<input
type="text"
value={formData.dns1}
onChange={(e) => setFormData({ ...formData, dns1: e.target.value })}
className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
placeholder="8.8.8.8"
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">
Secondary DNS
</label>
<input
type="text"
value={formData.dns2}
onChange={(e) => setFormData({ ...formData, dns2: e.target.value })}
className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
placeholder="8.8.4.4"
/>
</div>
</div>
{/* Role */}
<div>
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">
Interface Role
</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="block w-full rounded-lg border-border-dark bg-[#111a22] py-2.5 pl-3 pr-10 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary appearance-none"
>
<option value="">None</option>
<option value="Management">Management</option>
<option value="ISCSI">iSCSI</option>
<option value="Storage">Storage</option>
</select>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-bold text-text-secondary hover:text-white transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-bold text-white hover:bg-blue-600 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[18px]">save</span>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)
}