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.GET("/services/:name/logs", systemHandler.GetServiceLogs)
|
||||||
systemGroup.POST("/support-bundle", systemHandler.GenerateSupportBundle)
|
systemGroup.POST("/support-bundle", systemHandler.GenerateSupportBundle)
|
||||||
systemGroup.GET("/interfaces", systemHandler.ListNetworkInterfaces)
|
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
|
// 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})
|
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"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,6 +12,12 @@ import (
|
|||||||
"github.com/atlasos/calypso/internal/common/logger"
|
"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
|
// Service handles system management operations
|
||||||
type Service struct {
|
type Service struct {
|
||||||
logger *logger.Logger
|
logger *logger.Logger
|
||||||
@@ -183,6 +190,9 @@ type NetworkInterface struct {
|
|||||||
Status string `json:"status"` // "Connected" or "Down"
|
Status string `json:"status"` // "Connected" or "Down"
|
||||||
Speed string `json:"speed"` // e.g., "10 Gbps", "1 Gbps"
|
Speed string `json:"speed"` // e.g., "10 Gbps", "1 Gbps"
|
||||||
Role string `json:"role"` // "Management", "ISCSI", or empty
|
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
|
// 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
|
// Convert map to slice
|
||||||
var interfaces []NetworkInterface
|
var interfaces []NetworkInterface
|
||||||
s.logger.Debug("Converting interface map to slice", "map_size", len(interfaceMap))
|
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)
|
// Determine role based on interface name or IP (simple heuristic)
|
||||||
// You can enhance this with configuration file or database lookup
|
// You can enhance this with configuration file or database lookup
|
||||||
if strings.Contains(iface.Name, "eth") || strings.Contains(iface.Name, "ens") {
|
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))
|
s.logger.Info("Listed network interfaces", "count", len(interfaces))
|
||||||
return interfaces, nil
|
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"
|
status: string // "Connected" or "Down"
|
||||||
speed: string // e.g., "10 Gbps", "1 Gbps"
|
speed: string // e.g., "10 Gbps", "1 Gbps"
|
||||||
role: string // "Management", "ISCSI", or empty
|
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 = {
|
export const systemAPI = {
|
||||||
@@ -14,5 +36,16 @@ export const systemAPI = {
|
|||||||
const response = await apiClient.get<{ interfaces: NetworkInterface[] | null }>('/system/interfaces')
|
const response = await apiClient.get<{ interfaces: NetworkInterface[] | null }>('/system/interfaces')
|
||||||
return response.data.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 { 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'
|
import { systemAPI, NetworkInterface } from '@/api/system'
|
||||||
|
|
||||||
export default function System() {
|
export default function System() {
|
||||||
const [snmpEnabled, setSnmpEnabled] = useState(false)
|
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
|
// Fetch network interfaces
|
||||||
const { data: interfaces = [], isLoading: interfacesLoading } = useQuery({
|
const { data: interfaces = [], isLoading: interfacesLoading } = useQuery({
|
||||||
@@ -13,6 +34,31 @@ export default function System() {
|
|||||||
refetchInterval: 5000, // Refresh every 5 seconds
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
|
<div className="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
|
||||||
{/* Top Navigation */}
|
{/* Top Navigation */}
|
||||||
@@ -133,9 +179,51 @@ export default function System() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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">
|
<div className="relative" ref={menuRef}>
|
||||||
<span className="material-symbols-outlined">more_vert</span>
|
<button
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -306,18 +394,42 @@ export default function System() {
|
|||||||
<span className="material-symbols-outlined text-primary">schedule</span>
|
<span className="material-symbols-outlined text-primary">schedule</span>
|
||||||
<h2 className="text-lg font-bold text-white">Date & Time</h2>
|
<h2 className="text-lg font-bold text-white">Date & Time</h2>
|
||||||
</div>
|
</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>
|
||||||
<div className="p-6 flex flex-col gap-6">
|
<div className="p-6 flex flex-col gap-6">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">System Timezone</label>
|
<label className="mb-2 block text-xs font-medium text-text-secondary uppercase">System Timezone</label>
|
||||||
<div className="relative">
|
<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>Etc/UTC</option>
|
||||||
<option>America/New_York</option>
|
<option>Asia/Jakarta</option>
|
||||||
<option>Europe/London</option>
|
<option>Asia/Singapore</option>
|
||||||
|
<option>Asia/Bangkok</option>
|
||||||
|
<option>Asia/Manila</option>
|
||||||
<option>Asia/Tokyo</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>
|
</select>
|
||||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-white">
|
<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>
|
<span className="material-symbols-outlined text-sm">expand_more</span>
|
||||||
@@ -333,20 +445,25 @@ export default function System() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-border-dark">
|
{ntpServers.map((server, index) => (
|
||||||
<div className="flex items-center gap-3">
|
<div key={index} className="flex items-center justify-between rounded-lg bg-[#111a22] p-3 border border-border-dark">
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse"></div>
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-mono text-white">pool.ntp.org</span>
|
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,6 +542,187 @@ export default function System() {
|
|||||||
<div className="h-10"></div>
|
<div className="h-10"></div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user