Bump version to 1.0.9
Fix Wake-on-LAN: set SO_BROADCAST on UDP socket, send 3 magic packets, expose broadcast_addr and wake timeout fields in UI, add Test Wake endpoint, surface send errors in job status, bump default wake timeout to 180s.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
BINARY=syncserver
|
BINARY=syncserver
|
||||||
VERSION?=1.0.8
|
VERSION?=1.0.9
|
||||||
GO?=go
|
GO?=go
|
||||||
LDFLAGS=-s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
LDFLAGS=-s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
BUILD_FLAGS=CGO_ENABLED=0
|
BUILD_FLAGS=CGO_ENABLED=0
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+1
-1
@@ -20,7 +20,7 @@ import (
|
|||||||
"github.com/syncserver/internal/syncengine"
|
"github.com/syncserver/internal/syncengine"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.8"
|
var version = "1.0.9"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfgPath := flag.String("config", "", "Path to config.yaml")
|
cfgPath := flag.String("config", "", "Path to config.yaml")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/syncserver/internal/models"
|
"github.com/syncserver/internal/models"
|
||||||
|
"github.com/syncserver/internal/wol"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MachineHandler struct {
|
type MachineHandler struct {
|
||||||
@@ -74,7 +75,7 @@ func (h *MachineHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.SSHUser = "root"
|
req.SSHUser = "root"
|
||||||
}
|
}
|
||||||
if req.WakeTimeoutSeconds <= 0 {
|
if req.WakeTimeoutSeconds <= 0 {
|
||||||
req.WakeTimeoutSeconds = 120
|
req.WakeTimeoutSeconds = 180
|
||||||
}
|
}
|
||||||
if req.WakeCheckIntervalSeconds <= 0 {
|
if req.WakeCheckIntervalSeconds <= 0 {
|
||||||
req.WakeCheckIntervalSeconds = 5
|
req.WakeCheckIntervalSeconds = 5
|
||||||
@@ -172,6 +173,42 @@ func (h *MachineHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, machineToResp(*existing))
|
writeJSON(w, machineToResp(*existing))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *MachineHandler) TestWoL(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repo := models.NewMachineRepository(h.db)
|
||||||
|
m, err := repo.GetByID(id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "machine not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !m.WoLEnabled || m.MACAddress == nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "WoL is not enabled for this machine or MAC address is missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mac, err := wol.ParseMAC(*m.MACAddress)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid MAC address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bcast := ""
|
||||||
|
if m.BroadcastAddr != nil {
|
||||||
|
bcast = *m.BroadcastAddr
|
||||||
|
}
|
||||||
|
if err := wol.Send(mac, bcast); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": true, "sent": 3})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MachineHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
func (h *MachineHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func NewServer(cfg *config.Config, db *sql.DB, engine *syncengine.Engine) *Serve
|
|||||||
r.Get("/{id}", machineHandler.Get)
|
r.Get("/{id}", machineHandler.Get)
|
||||||
r.Put("/{id}", machineHandler.Update)
|
r.Put("/{id}", machineHandler.Update)
|
||||||
r.Delete("/{id}", machineHandler.Delete)
|
r.Delete("/{id}", machineHandler.Delete)
|
||||||
|
r.Post("/{id}/test-wol", machineHandler.TestWoL)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.With(auth.RequireAuth).Route("/sync-pairs", func(r chi.Router) {
|
r.With(auth.RequireAuth).Route("/sync-pairs", func(r chi.Router) {
|
||||||
|
|||||||
@@ -136,20 +136,26 @@ func (e *Engine) Run(ctx context.Context, jobID int64, pairID int64) error {
|
|||||||
if targetMachine.BroadcastAddr != nil {
|
if targetMachine.BroadcastAddr != nil {
|
||||||
bcast = *targetMachine.BroadcastAddr
|
bcast = *targetMachine.BroadcastAddr
|
||||||
}
|
}
|
||||||
if err := wol.Send(targetMachine.Host, mac, bcast); err != nil {
|
wolErr := wol.Send(mac, bcast)
|
||||||
slog.Warn("WoL failed", "host", targetMachine.Host, "error", err)
|
if wolErr != nil {
|
||||||
|
slog.Warn("WoL send failed", "host", targetMachine.Host, "error", wolErr)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("WoL magic packet sent", "host", targetMachine.Host, "mac", *targetMachine.MACAddress)
|
slog.Info("WoL magic packet sent", "host", targetMachine.Host, "mac", *targetMachine.MACAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout := time.Duration(targetMachine.WakeTimeoutSeconds) * time.Second
|
timeout := time.Duration(targetMachine.WakeTimeoutSeconds) * time.Second
|
||||||
interval := time.Duration(targetMachine.WakeCheckIntervalSeconds) * time.Second
|
interval := time.Duration(targetMachine.WakeCheckIntervalSeconds) * time.Second
|
||||||
if err := wol.WaitUntilReady(jobCtx, targetMachine.Host, remotePort, timeout, interval, false); err != nil {
|
if err := wol.WaitUntilReady(jobCtx, targetMachine.Host, remotePort, timeout, interval); err != nil {
|
||||||
e.setJobStatus(jobID, "failed")
|
e.setJobStatus(jobID, "failed")
|
||||||
e.setJobError(jobID, "wol_timeout", err.Error())
|
if wolErr != nil {
|
||||||
e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "failed", Line: err.Error()})
|
e.setJobError(jobID, "wol_send_failed", wolErr.Error())
|
||||||
return fmt.Errorf("machine not ready: %w", err)
|
e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "failed", Line: fmt.Sprintf("WoL send failed: %v", wolErr)})
|
||||||
}
|
return fmt.Errorf("WoL send failed: %w", wolErr)
|
||||||
|
}
|
||||||
|
e.setJobError(jobID, "wol_timeout", err.Error())
|
||||||
|
e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "failed", Line: err.Error()})
|
||||||
|
return fmt.Errorf("machine not ready: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-22
@@ -4,13 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os/exec"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrTimeout = fmt.Errorf("timeout waiting for machine to respond")
|
var ErrTimeout = fmt.Errorf("timeout waiting for machine to respond")
|
||||||
|
|
||||||
func WaitUntilReady(ctx context.Context, host string, sshPort int, maxWait, checkInterval time.Duration, usePing bool) error {
|
func WaitUntilReady(ctx context.Context, host string, sshPort int, maxWait, checkInterval time.Duration) error {
|
||||||
deadline := time.Now().Add(maxWait)
|
deadline := time.Now().Add(maxWait)
|
||||||
interval := checkInterval
|
interval := checkInterval
|
||||||
|
|
||||||
@@ -32,13 +31,6 @@ func WaitUntilReady(ctx context.Context, host string, sshPort int, maxWait, chec
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if usePing {
|
|
||||||
cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", host)
|
|
||||||
if err := cmd.Run(); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
return ErrTimeout
|
return ErrTimeout
|
||||||
}
|
}
|
||||||
@@ -53,16 +45,3 @@ func WaitUntilReady(ctx context.Context, host string, sshPort int, maxWait, chec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsHostReachable(host string, port int, timeout time.Duration) bool {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
||||||
defer cancel()
|
|
||||||
addr := fmt.Sprintf("%s:%d", host, port)
|
|
||||||
dialer := net.Dialer{Timeout: timeout}
|
|
||||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
|
||||||
if err == nil {
|
|
||||||
conn.Close()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
+27
-8
@@ -5,6 +5,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ func BuildMagicPacket(mac [6]byte) []byte {
|
|||||||
return packet
|
return packet
|
||||||
}
|
}
|
||||||
|
|
||||||
func Send(addr string, mac [6]byte, broadcastAddr string) error {
|
func Send(mac [6]byte, broadcastAddr string) error {
|
||||||
packet := BuildMagicPacket(mac)
|
packet := BuildMagicPacket(mac)
|
||||||
|
|
||||||
udpAddr := &net.UDPAddr{
|
udpAddr := &net.UDPAddr{
|
||||||
@@ -65,16 +66,34 @@ func Send(addr string, mac [6]byte, broadcastAddr string) error {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
sc, err := conn.SyscallConn()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting syscall conn: %w", err)
|
||||||
|
}
|
||||||
|
var setErr error
|
||||||
|
if err := sc.Control(func(fd uintptr) {
|
||||||
|
setErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1)
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("calling control: %w", err)
|
||||||
|
}
|
||||||
|
if setErr != nil {
|
||||||
|
return fmt.Errorf("enabling broadcast: %w", setErr)
|
||||||
|
}
|
||||||
if err := conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
if err := conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||||
return fmt.Errorf("setting broadcast mode: %w", err)
|
return fmt.Errorf("setting write deadline: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
n, err := conn.Write(packet)
|
for i := 0; i < 3; i++ {
|
||||||
if err != nil {
|
n, err := conn.Write(packet)
|
||||||
return fmt.Errorf("sending magic packet: %w", err)
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("sending magic packet: %w", err)
|
||||||
if n != len(packet) {
|
}
|
||||||
return fmt.Errorf("incomplete write: sent %d/%d bytes", n, len(packet))
|
if n != len(packet) {
|
||||||
|
return fmt.Errorf("incomplete write: sent %d/%d bytes", n, len(packet))
|
||||||
|
}
|
||||||
|
if i < 2 {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-2
@@ -1,13 +1,16 @@
|
|||||||
package wol
|
package wol
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseMAC(t *testing.T) {
|
func TestParseMAC(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
wantOK bool
|
wantOK bool
|
||||||
wantBytes [6]byte
|
wantBytes [6]byte
|
||||||
}{
|
}{
|
||||||
{"AA:BB:CC:DD:EE:FF", true, [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}},
|
{"AA:BB:CC:DD:EE:FF", true, [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}},
|
||||||
@@ -70,3 +73,99 @@ func TestBuildMagicPacket(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendInvalidBroadcast(t *testing.T) {
|
||||||
|
mac, _ := ParseMAC("aa:bb:cc:dd:ee:ff")
|
||||||
|
err := Send(mac, "not-an-ip")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Send with invalid broadcast address expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendThreePacketsToListener(t *testing.T) {
|
||||||
|
mac, _ := ParseMAC("aa:bb:cc:dd:ee:ff")
|
||||||
|
ln, err := net.ListenPacket("udp", "127.0.0.1:9")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("skipping test (need port 9, likely requires root): %v", err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
err = Send(mac, "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Send to 127.0.0.1: expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendPacketReceived(t *testing.T) {
|
||||||
|
mac, _ := ParseMAC("aa:bb:cc:dd:ee:ff")
|
||||||
|
ln, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("skipping receive test: %v", err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
addr := ln.LocalAddr().(*net.UDPAddr)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
conn, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: addr.Port})
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sc, err := conn.SyscallConn()
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := sc.Control(func(fd uintptr) {
|
||||||
|
err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1)
|
||||||
|
}); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
packet := BuildMagicPacket(mac)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
conn.Write(packet)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
errCh <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
expected := BuildMagicPacket(mac)
|
||||||
|
buf := make([]byte, 102)
|
||||||
|
rcvd := 0
|
||||||
|
for rcvd < 3 {
|
||||||
|
ln.SetReadDeadline(deadline)
|
||||||
|
n, _, err := ln.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected error reading packet %d: %v", rcvd+1, err)
|
||||||
|
}
|
||||||
|
if n != len(expected) {
|
||||||
|
t.Errorf("packet %d: got length %d, want %d", rcvd+1, n, len(expected))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(buf[:n]) != string(expected[:n]) {
|
||||||
|
t.Errorf("packet %d content mismatch", rcvd+1)
|
||||||
|
}
|
||||||
|
rcvd++
|
||||||
|
}
|
||||||
|
if rcvd != 3 {
|
||||||
|
t.Errorf("expected 3 packets, received %d", rcvd)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case sendErr := <-errCh:
|
||||||
|
if sendErr != nil {
|
||||||
|
t.Logf("sender goroutine error (ignored): %v", sendErr)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ export interface ErrorCodeInfo {
|
|||||||
const ERROR_CODES: Record<string, ErrorCodeInfo> = {
|
const ERROR_CODES: Record<string, ErrorCodeInfo> = {
|
||||||
wol_timeout: {
|
wol_timeout: {
|
||||||
title: "Machine didn't wake up",
|
title: "Machine didn't wake up",
|
||||||
hint: 'Check Wake-on-LAN settings, MAC address, and network connectivity',
|
hint: 'Waited for machine to respond and got no SSH reply. Make sure the server and the target machine are on the same L2 broadcast domain. For HP Microservers and similar hardware, increase the Wake timeout in the machine settings (180s or more).',
|
||||||
|
},
|
||||||
|
wol_send_failed: {
|
||||||
|
title: 'Wake-on-LAN packet failed to send',
|
||||||
|
hint: 'The magic packet could not be sent. Check the server logs for details. The server must be on the same broadcast domain as the target machine NIC and must have NET_ADMIN or CAP_NET_RAW capability.',
|
||||||
},
|
},
|
||||||
rsync_error: {
|
rsync_error: {
|
||||||
title: 'rsync failed',
|
title: 'rsync failed',
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
import { CopyButton } from '@/components/ui/CopyButton';
|
import { CopyButton } from '@/components/ui/CopyButton';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Pencil, Trash2, Plus, Server } from 'lucide-react';
|
import { Pencil, Trash2, Plus, Server, Zap } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ type MachineForm = {
|
|||||||
ssh_key_id: number | null;
|
ssh_key_id: number | null;
|
||||||
mac_address: string;
|
mac_address: string;
|
||||||
wol_enabled: boolean;
|
wol_enabled: boolean;
|
||||||
|
broadcast_addr: string;
|
||||||
wake_timeout_seconds: number;
|
wake_timeout_seconds: number;
|
||||||
wake_check_interval_seconds: number;
|
wake_check_interval_seconds: number;
|
||||||
};
|
};
|
||||||
@@ -52,7 +53,8 @@ const defaultForm: MachineForm = {
|
|||||||
ssh_key_id: null,
|
ssh_key_id: null,
|
||||||
mac_address: '',
|
mac_address: '',
|
||||||
wol_enabled: false,
|
wol_enabled: false,
|
||||||
wake_timeout_seconds: 120,
|
broadcast_addr: '',
|
||||||
|
wake_timeout_seconds: 180,
|
||||||
wake_check_interval_seconds: 5,
|
wake_check_interval_seconds: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,8 +96,9 @@ export default function Machines() {
|
|||||||
ssh_key_id: m.ssh_key_id,
|
ssh_key_id: m.ssh_key_id,
|
||||||
mac_address: m.mac_address || '',
|
mac_address: m.mac_address || '',
|
||||||
wol_enabled: m.wol_enabled,
|
wol_enabled: m.wol_enabled,
|
||||||
wake_timeout_seconds: m.wake_timeout_seconds,
|
broadcast_addr: m.broadcast_addr || '',
|
||||||
wake_check_interval_seconds: m.wake_check_interval_seconds,
|
wake_timeout_seconds: m.wake_timeout_seconds || 180,
|
||||||
|
wake_check_interval_seconds: m.wake_check_interval_seconds || 5,
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
@@ -124,6 +127,7 @@ export default function Machines() {
|
|||||||
ssh_key_id: form.ssh_key_id,
|
ssh_key_id: form.ssh_key_id,
|
||||||
mac_address: form.mac_address || null,
|
mac_address: form.mac_address || null,
|
||||||
wol_enabled: Boolean(form.wol_enabled),
|
wol_enabled: Boolean(form.wol_enabled),
|
||||||
|
broadcast_addr: form.broadcast_addr || null,
|
||||||
wake_timeout_seconds: Number(form.wake_timeout_seconds),
|
wake_timeout_seconds: Number(form.wake_timeout_seconds),
|
||||||
wake_check_interval_seconds: Number(form.wake_check_interval_seconds),
|
wake_check_interval_seconds: Number(form.wake_check_interval_seconds),
|
||||||
};
|
};
|
||||||
@@ -153,6 +157,15 @@ export default function Machines() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTestWol(m: Machine) {
|
||||||
|
try {
|
||||||
|
await api<{ ok: boolean; sent: number }>(`/api/machines/${m.id}/test-wol`, { method: 'POST' });
|
||||||
|
toast.success(`Magic packet sent (${m.mac_address})`);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(`Wake failed: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function keyLabel(id: number | null) {
|
function keyLabel(id: number | null) {
|
||||||
if (!id) return 'Server Key';
|
if (!id) return 'Server Key';
|
||||||
const k = sshKeys.find(k => k.id === id);
|
const k = sshKeys.find(k => k.id === id);
|
||||||
@@ -194,6 +207,7 @@ export default function Machines() {
|
|||||||
<TableHead>Host</TableHead>
|
<TableHead>Host</TableHead>
|
||||||
<TableHead>SSH Key</TableHead>
|
<TableHead>SSH Key</TableHead>
|
||||||
<TableHead>WoL</TableHead>
|
<TableHead>WoL</TableHead>
|
||||||
|
<TableHead>WoL Timeout</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="w-24">Actions</TableHead>
|
<TableHead className="w-24">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -217,6 +231,15 @@ export default function Machines() {
|
|||||||
<span className="text-fg-subtle text-xs">No</span>
|
<span className="text-fg-subtle text-xs">No</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{m.wol_enabled ? (
|
||||||
|
<span className="text-xs text-fg-muted">
|
||||||
|
{m.wake_timeout_seconds}s
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-fg-subtle text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusBadge status={m.status} />
|
<StatusBadge status={m.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -230,6 +253,16 @@ export default function Machines() {
|
|||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{m.wol_enabled && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => handleTestWol(m)}
|
||||||
|
title="Test Wake-on-LAN"
|
||||||
|
>
|
||||||
|
<Zap className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -364,6 +397,60 @@ export default function Machines() {
|
|||||||
Enable Wake-on-LAN
|
Enable Wake-on-LAN
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
{form.wol_enabled && (
|
||||||
|
<div className="space-y-3 p-3 border border-border rounded-card bg-surface-raised">
|
||||||
|
<p className="text-xs text-fg-subtle font-medium">Wake-on-LAN Settings</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="broadcast_addr">Broadcast Address</Label>
|
||||||
|
<Input
|
||||||
|
id="broadcast_addr"
|
||||||
|
placeholder="255.255.255.255"
|
||||||
|
value={form.broadcast_addr}
|
||||||
|
onChange={e =>
|
||||||
|
setForm({ ...form, broadcast_addr: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-fg-subtle">
|
||||||
|
Leave blank to use 255.255.255.255. Set to your subnet broadcast
|
||||||
|
address if the server has multiple interfaces.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="wake_timeout_seconds">Wake timeout (s)</Label>
|
||||||
|
<Input
|
||||||
|
id="wake_timeout_seconds"
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={600}
|
||||||
|
value={form.wake_timeout_seconds}
|
||||||
|
onChange={e =>
|
||||||
|
setForm({ ...form, wake_timeout_seconds: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-fg-subtle">
|
||||||
|
HP Microservers typically need ~180s.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="wake_check_interval_seconds">Check interval (s)</Label>
|
||||||
|
<Input
|
||||||
|
id="wake_check_interval_seconds"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
value={form.wake_check_interval_seconds}
|
||||||
|
onChange={e =>
|
||||||
|
setForm({ ...form, wake_check_interval_seconds: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-fg-subtle">
|
||||||
|
How often to poll SSH readiness.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user