mirror of https://github.com/jetkvm/kvm.git
Compare commits
7 Commits
607e906c18
...
746779b907
| Author | SHA1 | Date |
|---|---|---|
|
|
746779b907 | |
|
|
31ea366e51 | |
|
|
7f2dcc84b4 | |
|
|
1097deeaf8 | |
|
|
d6d113e253 | |
|
|
7d2fe79993 | |
|
|
8a54d4c645 |
|
|
@ -10,5 +10,6 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"git.ignoreLimitWarning": true,
|
"git.ignoreLimitWarning": true,
|
||||||
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo"
|
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo",
|
||||||
|
"cmake.ignoreCMakeListsMissing": true
|
||||||
}
|
}
|
||||||
|
|
@ -932,6 +932,10 @@ func rpcSetCloudUrl(apiUrl string, appUrl string) error {
|
||||||
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
|
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if publicIPState != nil {
|
||||||
|
publicIPState.SetCloudflareEndpoint(apiUrl)
|
||||||
|
}
|
||||||
|
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -1248,4 +1252,6 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
||||||
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
||||||
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
||||||
|
"getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}},
|
||||||
|
"checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
main.go
1
main.go
|
|
@ -126,6 +126,7 @@ func Main() {
|
||||||
|
|
||||||
// As websocket client already checks if the cloud token is set, we can start it here.
|
// As websocket client already checks if the cloud token is set, we can start it here.
|
||||||
go RunWebsocketClient()
|
go RunWebsocketClient()
|
||||||
|
initPublicIPState()
|
||||||
|
|
||||||
initSerialPort()
|
initSerialPort()
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
|
|
|
||||||
70
network.go
70
network.go
|
|
@ -3,12 +3,17 @@ package kvm
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jetkvm/kvm/internal/confparser"
|
"github.com/jetkvm/kvm/internal/confparser"
|
||||||
"github.com/jetkvm/kvm/internal/mdns"
|
"github.com/jetkvm/kvm/internal/mdns"
|
||||||
"github.com/jetkvm/kvm/internal/network/types"
|
"github.com/jetkvm/kvm/internal/network/types"
|
||||||
|
"github.com/jetkvm/kvm/pkg/myip"
|
||||||
"github.com/jetkvm/kvm/pkg/nmlite"
|
"github.com/jetkvm/kvm/pkg/nmlite"
|
||||||
|
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -17,6 +22,7 @@ const (
|
||||||
|
|
||||||
var (
|
var (
|
||||||
networkManager *nmlite.NetworkManager
|
networkManager *nmlite.NetworkManager
|
||||||
|
publicIPState *myip.PublicIPState
|
||||||
)
|
)
|
||||||
|
|
||||||
type RpcNetworkSettings struct {
|
type RpcNetworkSettings struct {
|
||||||
|
|
@ -115,6 +121,14 @@ func networkStateChanged(_ string, state types.InterfaceState) {
|
||||||
if state.Online {
|
if state.Online {
|
||||||
networkLogger.Info().Msg("network state changed to online, triggering time sync")
|
networkLogger.Info().Msg("network state changed to online, triggering time sync")
|
||||||
triggerTimeSyncOnNetworkStateChange()
|
triggerTimeSyncOnNetworkStateChange()
|
||||||
|
|
||||||
|
if publicIPState != nil {
|
||||||
|
publicIPState.SetIPv4AndIPv6(state.IPv4Ready, state.IPv6Ready)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if publicIPState != nil {
|
||||||
|
publicIPState.SetIPv4AndIPv6(false, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// always restart mDNS when the network state changes
|
// always restart mDNS when the network state changes
|
||||||
|
|
@ -164,6 +178,40 @@ func initNetwork() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initPublicIPState() {
|
||||||
|
// the feature will be only enabled if the cloud has been adopted
|
||||||
|
// due to privacy reasons
|
||||||
|
|
||||||
|
// but it will be initialized anyway to avoid nil pointer dereferences
|
||||||
|
ps := myip.NewPublicIPState(&myip.PublicIPStateConfig{
|
||||||
|
Logger: networkLogger,
|
||||||
|
CloudflareEndpoint: config.CloudURL,
|
||||||
|
APIEndpoint: "",
|
||||||
|
IPv4: false,
|
||||||
|
IPv6: false,
|
||||||
|
HttpClientGetter: func(family int) *http.Client {
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
|
||||||
|
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
netType := network
|
||||||
|
switch family {
|
||||||
|
case link.AfInet:
|
||||||
|
netType = "tcp4"
|
||||||
|
case link.AfInet6:
|
||||||
|
netType = "tcp6"
|
||||||
|
}
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, netType, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
publicIPState = ps
|
||||||
|
}
|
||||||
|
|
||||||
func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
|
func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
|
||||||
if nm == nil {
|
if nm == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -312,3 +360,25 @@ func rpcToggleDHCPClient() error {
|
||||||
|
|
||||||
return rpcReboot(true)
|
return rpcReboot(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetPublicIPAddresses(refresh bool) ([]myip.PublicIP, error) {
|
||||||
|
if publicIPState == nil {
|
||||||
|
return nil, fmt.Errorf("public IP state not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if refresh {
|
||||||
|
if err := publicIPState.ForceUpdate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicIPState.GetAddresses(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcCheckPublicIPAddresses() error {
|
||||||
|
if publicIPState == nil {
|
||||||
|
return fmt.Errorf("public IP state not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicIPState.ForceUpdate()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
package myip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ps *PublicIPState) request(ctx context.Context, url string, family int) ([]byte, error) {
|
||||||
|
client := ps.httpClient(family)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error creating request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error sending request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCloudflare uses cdn-cgi/trace to get the public IP address
|
||||||
|
func (ps *PublicIPState) checkCloudflare(ctx context.Context, family int) (*PublicIP, error) {
|
||||||
|
u, err := url.JoinPath(ps.cloudflareEndpoint, "/cdn-cgi/trace")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error joining path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ps.request(ctx, u, family)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(string(body), "\n") {
|
||||||
|
key, value, ok := strings.Cut(line, "=")
|
||||||
|
if !ok || key != "ip" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PublicIP{
|
||||||
|
IPAddress: net.ParseIP(value),
|
||||||
|
LastUpdated: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no IP address found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAPI uses the API endpoint to get the public IP address
|
||||||
|
func (ps *PublicIPState) checkAPI(_ context.Context, _ int) (*PublicIP, error) {
|
||||||
|
return nil, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkIPs checks both IPv4 and IPv6 public IP addresses in parallel
|
||||||
|
// and updates the IPAddresses slice with the results
|
||||||
|
func (ps *PublicIPState) checkIPs(ctx context.Context, checkIPv4, checkIPv6 bool) error {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
var ips []PublicIP
|
||||||
|
var errors []error
|
||||||
|
|
||||||
|
checkFamily := func(family int, familyName string) {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(f int, name string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
ip, err := ps.checkIPForFamily(ctx, f)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Errorf("%s check failed: %w", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ip != nil {
|
||||||
|
ips = append(ips, *ip)
|
||||||
|
}
|
||||||
|
}(family, familyName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkIPv4 {
|
||||||
|
checkFamily(link.AfInet, "IPv4")
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkIPv6 {
|
||||||
|
checkFamily(link.AfInet6, "IPv6")
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if len(ips) > 0 {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
|
||||||
|
ps.addresses = ips
|
||||||
|
ps.lastUpdated = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errors) > 0 && len(ips) == 0 {
|
||||||
|
return errors[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *PublicIPState) checkIPForFamily(ctx context.Context, family int) (*PublicIP, error) {
|
||||||
|
if ps.apiEndpoint != "" {
|
||||||
|
ip, err := ps.checkAPI(ctx, family)
|
||||||
|
if err == nil && ip != nil {
|
||||||
|
return ip, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.cloudflareEndpoint != "" {
|
||||||
|
ip, err := ps.checkCloudflare(ctx, family)
|
||||||
|
if err == nil && ip != nil {
|
||||||
|
return ip, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("all IP check methods failed for family %d", family)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
package myip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PublicIP struct {
|
||||||
|
IPAddress net.IP `json:"ip"`
|
||||||
|
LastUpdated time.Time `json:"last_updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HttpClientGetter func(family int) *http.Client
|
||||||
|
|
||||||
|
type PublicIPState struct {
|
||||||
|
addresses []PublicIP
|
||||||
|
lastUpdated time.Time
|
||||||
|
|
||||||
|
cloudflareEndpoint string // cdn-cgi/trace domain
|
||||||
|
apiEndpoint string // api endpoint
|
||||||
|
ipv4 bool
|
||||||
|
ipv6 bool
|
||||||
|
httpClient HttpClientGetter
|
||||||
|
logger *zerolog.Logger
|
||||||
|
|
||||||
|
timer *time.Timer
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicIPStateConfig struct {
|
||||||
|
CloudflareEndpoint string
|
||||||
|
APIEndpoint string
|
||||||
|
IPv4 bool
|
||||||
|
IPv6 bool
|
||||||
|
HttpClientGetter HttpClientGetter
|
||||||
|
Logger *zerolog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripURLPath(s string) string {
|
||||||
|
parsed, err := url.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
scheme := parsed.Scheme
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s://%s", scheme, parsed.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPublicIPState creates a new PublicIPState
|
||||||
|
func NewPublicIPState(config *PublicIPStateConfig) *PublicIPState {
|
||||||
|
if config.Logger == nil {
|
||||||
|
config.Logger = logging.GetSubsystemLogger("publicip")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
ps := &PublicIPState{
|
||||||
|
addresses: make([]PublicIP, 0),
|
||||||
|
lastUpdated: time.Now(),
|
||||||
|
cloudflareEndpoint: stripURLPath(config.CloudflareEndpoint),
|
||||||
|
apiEndpoint: config.APIEndpoint,
|
||||||
|
ipv4: config.IPv4,
|
||||||
|
ipv6: config.IPv6,
|
||||||
|
httpClient: config.HttpClientGetter,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
logger: config.Logger,
|
||||||
|
}
|
||||||
|
// Start the timer automatically
|
||||||
|
ps.Start()
|
||||||
|
return ps
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFamily sets if we need to track IPv4 and IPv6 public IP addresses
|
||||||
|
func (ps *PublicIPState) SetIPv4AndIPv6(ipv4, ipv6 bool) {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
|
||||||
|
ps.ipv4 = ipv4
|
||||||
|
ps.ipv6 = ipv6
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetIPv4 sets if we need to track IPv4 public IP addresses
|
||||||
|
func (ps *PublicIPState) SetIPv4(ipv4 bool) {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
|
||||||
|
ps.ipv4 = ipv4
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetIPv6 sets if we need to track IPv6 public IP addresses
|
||||||
|
func (ps *PublicIPState) SetIPv6(ipv6 bool) {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
|
||||||
|
ps.ipv6 = ipv6
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCloudflareEndpoint sets the Cloudflare endpoint
|
||||||
|
func (ps *PublicIPState) SetCloudflareEndpoint(endpoint string) {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
|
||||||
|
ps.cloudflareEndpoint = stripURLPath(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAPIEndpoint sets the API endpoint
|
||||||
|
func (ps *PublicIPState) SetAPIEndpoint(endpoint string) {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
|
||||||
|
ps.apiEndpoint = endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAddresses returns the public IP addresses
|
||||||
|
func (ps *PublicIPState) GetAddresses() []PublicIP {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
|
||||||
|
return ps.addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the timer loop to check public IP addresses periodically
|
||||||
|
func (ps *PublicIPState) Start() {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
|
||||||
|
// Stop any existing timer
|
||||||
|
if ps.timer != nil {
|
||||||
|
ps.timer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.cancel != nil {
|
||||||
|
ps.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new context and cancel function
|
||||||
|
ps.ctx, ps.cancel = context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
// Start the timer loop in a goroutine
|
||||||
|
go ps.timerLoop(ps.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the timer loop
|
||||||
|
func (ps *PublicIPState) Stop() {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
|
||||||
|
if ps.cancel != nil {
|
||||||
|
ps.cancel()
|
||||||
|
ps.cancel = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.timer != nil {
|
||||||
|
ps.timer.Stop()
|
||||||
|
ps.timer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceUpdate forces an update of the public IP addresses
|
||||||
|
func (ps *PublicIPState) ForceUpdate() error {
|
||||||
|
return ps.checkIPs(context.Background(), true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// timerLoop runs the periodic IP check loop
|
||||||
|
func (ps *PublicIPState) timerLoop(ctx context.Context) {
|
||||||
|
timer := time.NewTimer(5 * time.Minute)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
// Store timer reference for Stop() to access
|
||||||
|
ps.mu.Lock()
|
||||||
|
ps.timer = timer
|
||||||
|
checkIPv4 := ps.ipv4
|
||||||
|
checkIPv6 := ps.ipv6
|
||||||
|
ps.mu.Unlock()
|
||||||
|
|
||||||
|
// Perform initial check immediately
|
||||||
|
checkIPs := func() {
|
||||||
|
if err := ps.checkIPs(ctx, checkIPv4, checkIPv6); err != nil {
|
||||||
|
ps.logger.Error().Err(err).Msg("failed to check public IP addresses")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkIPs()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
// Perform the check
|
||||||
|
checkIPs()
|
||||||
|
|
||||||
|
// Reset the timer for the next check
|
||||||
|
timer.Reset(5 * time.Minute)
|
||||||
|
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Timer was stopped
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -897,5 +897,11 @@
|
||||||
"wake_on_lan_invalid_mac": "Invalid MAC address",
|
"wake_on_lan_invalid_mac": "Invalid MAC address",
|
||||||
"wake_on_lan_magic_sent_success": "Magic Packet sent successfully",
|
"wake_on_lan_magic_sent_success": "Magic Packet sent successfully",
|
||||||
"welcome_to_jetkvm": "Welcome to JetKVM",
|
"welcome_to_jetkvm": "Welcome to JetKVM",
|
||||||
"welcome_to_jetkvm_description": "Control any computer remotely"
|
"welcome_to_jetkvm_description": "Control any computer remotely","connection_stats_remote_ip_address": "Remote IP Address",
|
||||||
|
"connection_stats_remote_ip_address_description": "The IP address of the remote device.",
|
||||||
|
"connection_stats_remote_ip_address_copy_error": "Failed to copy remote IP address",
|
||||||
|
"connection_stats_remote_ip_address_copy_success": "Remote IP address { ip } copied to clipboard",
|
||||||
|
"public_ip_card_header": "Public IP addresses",
|
||||||
|
"public_ip_card_refresh": "Refresh",
|
||||||
|
"public_ip_card_refresh_error": "Failed to refresh public IP addresses: {error}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "kvm-ui",
|
"name": "kvm-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2025.10.24.2140",
|
"version": "2025.11.07.2130",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.20.0"
|
"node": "^22.20.0"
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"cva": "^1.0.0-beta.4",
|
"cva": "^1.0.0-beta.4",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.19",
|
||||||
"eslint-import-resolver-alias": "^1.1.2",
|
"eslint-import-resolver-alias": "^1.1.2",
|
||||||
"focus-trap-react": "^11.0.4",
|
"focus-trap-react": "^11.0.4",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
|
|
@ -47,52 +47,52 @@
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-animate-height": "^3.2.3",
|
"react-animate-height": "^3.2.3",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.66.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router": "^7.9.5",
|
"react-router": "^7.9.5",
|
||||||
"react-simple-keyboard": "^3.8.131",
|
"react-simple-keyboard": "^3.8.132",
|
||||||
"react-use-websocket": "^4.13.0",
|
"react-use-websocket": "^4.13.0",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
"recharts": "^3.3.0",
|
"recharts": "^3.3.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"validator": "^13.15.15",
|
"validator": "^13.15.20",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.1",
|
"@eslint/compat": "^1.4.1",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "^9.39.0",
|
"@eslint/js": "^9.39.1",
|
||||||
"@inlang/cli": "^3.0.12",
|
"@inlang/cli": "^3.0.12",
|
||||||
"@inlang/paraglide-js": "^2.4.0",
|
"@inlang/paraglide-js": "^2.4.0",
|
||||||
"@inlang/plugin-m-function-matcher": "^2.1.0",
|
"@inlang/plugin-m-function-matcher": "^2.1.0",
|
||||||
"@inlang/plugin-message-format": "^4.0.0",
|
"@inlang/plugin-message-format": "^4.0.0",
|
||||||
"@inlang/sdk": "^2.4.9",
|
"@inlang/sdk": "^2.4.9",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.16",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"@types/validator": "^13.15.3",
|
"@types/validator": "^13.15.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.46.2",
|
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||||
"@typescript-eslint/parser": "^8.46.2",
|
"@typescript-eslint/parser": "^8.46.3",
|
||||||
"@vitejs/plugin-react-swc": "^4.2.0",
|
"@vitejs/plugin-react-swc": "^4.2.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.38.0",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.5.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.17",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.1.12",
|
"vite": "^7.1.12",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { LuRefreshCcw } from "react-icons/lu";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@components/Button";
|
||||||
|
import { GridCard } from "@components/Card";
|
||||||
|
import { PublicIP } from "@hooks/stores";
|
||||||
|
import { m } from "@localizations/messages.js";
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
|
|
||||||
|
export default function PublicIPCard() {
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
|
const [publicIPs, setPublicIPs] = useState<PublicIP[]>([]);
|
||||||
|
const refreshPublicIPs = useCallback(() => {
|
||||||
|
send("getPublicIPAddresses", { refresh: true }, (resp: JsonRpcResponse) => {
|
||||||
|
setPublicIPs([]);
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(m.public_ip_card_refresh_error({ error: resp.error.data || m.unknown_error() }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const publicIPs = resp.result as PublicIP[];
|
||||||
|
setPublicIPs(publicIPs.sort(({ ip: aIp }, { ip: bIp }) => {
|
||||||
|
const aIsIPv6 = aIp.includes(":");
|
||||||
|
const bIsIPv6 = bIp.includes(":");
|
||||||
|
if (aIsIPv6 && !bIsIPv6) return 1;
|
||||||
|
if (!aIsIPv6 && bIsIPv6) return -1;
|
||||||
|
return aIp.localeCompare(bIp);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}, [send, setPublicIPs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshPublicIPs();
|
||||||
|
}, [refreshPublicIPs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridCard>
|
||||||
|
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||||
|
{m.public_ip_card_header()}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
type="button"
|
||||||
|
className="text-red-500"
|
||||||
|
text={m.public_ip_card_refresh()}
|
||||||
|
LeadingIcon={LuRefreshCcw}
|
||||||
|
onClick={refreshPublicIPs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{publicIPs.length === 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="animate-pulse space-y-2">
|
||||||
|
<div className="h-4 w-1/4 rounded bg-slate-200 dark:bg-slate-700" />
|
||||||
|
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
|
||||||
|
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-x-6 gap-y-2">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{publicIPs?.map(ip => (
|
||||||
|
<div key={ip.ip} className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{ip.ip}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GridCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { useInterval } from "usehooks-ts";
|
import { useInterval } from "usehooks-ts";
|
||||||
|
import { LuCopy } from "react-icons/lu";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { m } from "@localizations/messages.js";
|
import { m } from "@localizations/messages.js";
|
||||||
import { useRTCStore, useUiStore } from "@hooks/stores";
|
import { useRTCStore, useUiStore } from "@hooks/stores";
|
||||||
|
|
@ -6,6 +8,10 @@ import { createChartArray, Metric } from "@components/Metric";
|
||||||
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
|
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
|
||||||
import SidebarHeader from "@components/SidebarHeader";
|
import SidebarHeader from "@components/SidebarHeader";
|
||||||
import { someIterable } from "@/utils";
|
import { someIterable } from "@/utils";
|
||||||
|
import { GridCard } from "@components/Card";
|
||||||
|
import { Button } from "@components/Button";
|
||||||
|
import { useCopyToClipboard } from "@components/useCopyToClipBoard";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
export default function ConnectionStatsSidebar() {
|
export default function ConnectionStatsSidebar() {
|
||||||
const { sidebarView, setSidebarView } = useUiStore();
|
const { sidebarView, setSidebarView } = useUiStore();
|
||||||
|
|
@ -21,6 +27,8 @@ export default function ConnectionStatsSidebar() {
|
||||||
appendDiskDataChannelStats,
|
appendDiskDataChannelStats,
|
||||||
} = useRTCStore();
|
} = useRTCStore();
|
||||||
|
|
||||||
|
const [remoteIPAddress, setRemoteIPAddress] = useState<string | null>(null);
|
||||||
|
|
||||||
useInterval(function collectWebRTCStats() {
|
useInterval(function collectWebRTCStats() {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!mediaStream) return;
|
if (!mediaStream) return;
|
||||||
|
|
@ -49,6 +57,7 @@ export default function ConnectionStatsSidebar() {
|
||||||
} else if (report.type === "remote-candidate") {
|
} else if (report.type === "remote-candidate") {
|
||||||
if (successfulRemoteCandidateId === report.id) {
|
if (successfulRemoteCandidateId === report.id) {
|
||||||
appendRemoteCandidateStats(report);
|
appendRemoteCandidateStats(report);
|
||||||
|
setRemoteIPAddress(report.address);
|
||||||
}
|
}
|
||||||
} else if (report.type === "data-channel" && report.label === "disk") {
|
} else if (report.type === "data-channel" && report.label === "disk") {
|
||||||
appendDiskDataChannelStats(report);
|
appendDiskDataChannelStats(report);
|
||||||
|
|
@ -93,6 +102,8 @@ export default function ConnectionStatsSidebar() {
|
||||||
return { date: d.date, metric: valueMs };
|
return { date: d.date, metric: valueMs };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { copy } = useCopyToClipboard();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
|
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
|
||||||
<SidebarHeader title={m.connection_stats_sidebar()} setSidebarView={setSidebarView} />
|
<SidebarHeader title={m.connection_stats_sidebar()} setSidebarView={setSidebarView} />
|
||||||
|
|
@ -106,6 +117,27 @@ export default function ConnectionStatsSidebar() {
|
||||||
title={m.connection_stats_connection()}
|
title={m.connection_stats_connection()}
|
||||||
description={m.connection_stats_connection_description()}
|
description={m.connection_stats_connection_description()}
|
||||||
/>
|
/>
|
||||||
|
{remoteIPAddress && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{m.connection_stats_remote_ip_address()}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<GridCard cardClassName="rounded-r-none">
|
||||||
|
<div className="h-[34px] flex items-center text-xs select-all text-black font-mono dark:text-white px-3 ">
|
||||||
|
{remoteIPAddress}
|
||||||
|
</div>
|
||||||
|
</GridCard>
|
||||||
|
<Button className="rounded-l-none border-l-slate-800/30 dark:border-slate-300/20" size="SM" type="button" theme="light" LeadingIcon={LuCopy} onClick={async () => {
|
||||||
|
if (await copy(remoteIPAddress)) {
|
||||||
|
notifications.success((m.connection_stats_remote_ip_address_copy_success({ ip: remoteIPAddress })));
|
||||||
|
} else {
|
||||||
|
notifications.error(m.connection_stats_remote_ip_address_copy_error());
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Metric
|
<Metric
|
||||||
title={m.connection_stats_round_trip_time()}
|
title={m.connection_stats_round_trip_time()}
|
||||||
description={m.connection_stats_round_trip_time_description()}
|
description={m.connection_stats_round_trip_time_description()}
|
||||||
|
|
|
||||||
|
|
@ -603,6 +603,9 @@ export interface UpdateState {
|
||||||
|
|
||||||
updateErrorMessage: string | null;
|
updateErrorMessage: string | null;
|
||||||
setUpdateErrorMessage: (errorMessage: string) => void;
|
setUpdateErrorMessage: (errorMessage: string) => void;
|
||||||
|
|
||||||
|
shouldReload: boolean;
|
||||||
|
setShouldReload: (reloadRequired: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUpdateStore = create<UpdateState>(set => ({
|
export const useUpdateStore = create<UpdateState>(set => ({
|
||||||
|
|
@ -640,6 +643,9 @@ export const useUpdateStore = create<UpdateState>(set => ({
|
||||||
updateErrorMessage: null,
|
updateErrorMessage: null,
|
||||||
setUpdateErrorMessage: (errorMessage: string) =>
|
setUpdateErrorMessage: (errorMessage: string) =>
|
||||||
set({ updateErrorMessage: errorMessage }),
|
set({ updateErrorMessage: errorMessage }),
|
||||||
|
|
||||||
|
shouldReload: false,
|
||||||
|
setShouldReload: (reloadRequired: boolean) => set({ shouldReload: reloadRequired }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export type UsbConfigModalViews = "updateUsbConfig" | "updateUsbConfigSuccess";
|
export type UsbConfigModalViews = "updateUsbConfig" | "updateUsbConfigSuccess";
|
||||||
|
|
@ -752,6 +758,11 @@ export interface IPv6Address {
|
||||||
flag_tentative?: boolean;
|
flag_tentative?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicIP {
|
||||||
|
ip: string;
|
||||||
|
last_updated: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NetworkState {
|
export interface NetworkState {
|
||||||
interface_name?: string;
|
interface_name?: string;
|
||||||
mac_address?: string;
|
mac_address?: string;
|
||||||
|
|
@ -850,12 +861,12 @@ export interface MacrosState {
|
||||||
loadMacros: () => Promise<void>;
|
loadMacros: () => Promise<void>;
|
||||||
saveMacros: (macros: KeySequence[]) => Promise<void>;
|
saveMacros: (macros: KeySequence[]) => Promise<void>;
|
||||||
sendFn:
|
sendFn:
|
||||||
| ((
|
| ((
|
||||||
method: string,
|
method: string,
|
||||||
params: unknown,
|
params: unknown,
|
||||||
callback?: ((resp: JsonRpcResponse) => void) | undefined,
|
callback?: ((resp: JsonRpcResponse) => void) | undefined,
|
||||||
) => void)
|
) => void)
|
||||||
| null;
|
| null;
|
||||||
setSendFn: (
|
setSendFn: (
|
||||||
sendFn: (
|
sendFn: (
|
||||||
method: string,
|
method: string,
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,24 @@ export default function SettingsGeneralUpdateRoute() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { updateSuccess } = location.state || {};
|
const { updateSuccess } = location.state || {};
|
||||||
|
|
||||||
const { setModalView, otaState } = useUpdateStore();
|
const { setModalView, otaState, shouldReload, setShouldReload } = useUpdateStore();
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
const onClose = useCallback(async () => {
|
const onClose = useCallback(async () => {
|
||||||
navigate(".."); // back to the devices.$id.settings page
|
navigate(".."); // back to the devices.$id.settings page
|
||||||
// Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation.
|
|
||||||
await sleep(1000);
|
if (shouldReload) {
|
||||||
window.location.reload(); // force a full reload to ensure the current device/cloud UI version is loaded
|
setShouldReload(false);
|
||||||
}, [navigate]);
|
await sleep(1000); // Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation.
|
||||||
|
window.location.reload(); // force a full reload to ensure the current device/cloud UI version is loaded
|
||||||
|
}
|
||||||
|
}, [navigate, setShouldReload, shouldReload]);
|
||||||
|
|
||||||
const onConfirmUpdate = useCallback(() => {
|
const onConfirmUpdate = useCallback(() => {
|
||||||
|
setShouldReload(true);
|
||||||
send("tryUpdate", {});
|
send("tryUpdate", {});
|
||||||
setModalView("updating");
|
setModalView("updating");
|
||||||
}, [send, setModalView]);
|
}, [send, setModalView, setShouldReload]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (otaState.updating) {
|
if (otaState.updating) {
|
||||||
|
|
@ -133,6 +137,7 @@ function LoadingState({
|
||||||
const { setModalView } = useUpdateStore();
|
const { setModalView } = useUpdateStore();
|
||||||
|
|
||||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
const signal = abortControllerRef.current.signal;
|
const signal = abortControllerRef.current.signal;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import validator from "validator";
|
import validator from "validator";
|
||||||
|
|
||||||
|
import PublicIPCard from "@components/PublicIPCard";
|
||||||
import { NetworkSettings, NetworkState, useNetworkStateStore, useRTCStore } from "@hooks/stores";
|
import { NetworkSettings, NetworkState, useNetworkStateStore, useRTCStore } from "@hooks/stores";
|
||||||
import { useJsonRpc } from "@hooks/useJsonRpc";
|
import { useJsonRpc } from "@hooks/useJsonRpc";
|
||||||
import AutoHeight from "@components/AutoHeight";
|
import AutoHeight from "@components/AutoHeight";
|
||||||
|
|
@ -460,6 +461,8 @@ export default function SettingsNetworkRoute() {
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
<PublicIPCard />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<AutoHeight>
|
<AutoHeight>
|
||||||
{formState.isLoading ? (
|
{formState.isLoading ? (
|
||||||
|
|
@ -540,25 +543,25 @@ export default function SettingsNetworkRoute() {
|
||||||
</AutoHeight>
|
</AutoHeight>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ isLLDPAvailable &&
|
{isLLDPAvailable &&
|
||||||
(
|
(
|
||||||
<div className="hidden space-y-4">
|
<div className="hidden space-y-4">
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={m.network_ll_dp_title()}
|
title={m.network_ll_dp_title()}
|
||||||
description={m.network_ll_dp_description()}
|
description={m.network_ll_dp_description()}
|
||||||
>
|
>
|
||||||
<SelectMenuBasic
|
<SelectMenuBasic
|
||||||
size="SM"
|
size="SM"
|
||||||
options={[
|
options={[
|
||||||
{ value: "disabled", label: m.network_ll_dp_disabled() },
|
{ value: "disabled", label: m.network_ll_dp_disabled() },
|
||||||
{ value: "basic", label: m.network_ll_dp_basic() },
|
{ value: "basic", label: m.network_ll_dp_basic() },
|
||||||
{ value: "all", label: m.network_ll_dp_all() },
|
{ value: "all", label: m.network_ll_dp_all() },
|
||||||
]}
|
]}
|
||||||
{...register("lldp_mode")}
|
{...register("lldp_mode")}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
<div className="animate-fadeInStill animation-duration-300">
|
<div className="animate-fadeInStill animation-duration-300">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue