fix network interface information fetch from OS
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user