Bump version to 1.0.11

Machine status probe on page load:
- POST /api/machines/refresh probes all machines in parallel (max 20 concurrent, 1.5s timeout)
- Updates DB status and broadcasts via SSE to all connected browser tabs
- Server-side throttle: ignores refresh requests within 10s
- Machines.tsx and Dashboard.tsx fire probe on mount
- Visual "Checking machine status..." indicator in Machines table
- MachineHandler now accepts *Engine for ProbeAllMachines access
This commit is contained in:
2026-07-08 21:44:50 -04:00
parent a77f84ad34
commit 1798fc6804
8 changed files with 115 additions and 12 deletions
+15 -3
View File
@@ -10,15 +10,17 @@ import (
"github.com/go-chi/chi/v5"
"github.com/syncserver/internal/models"
"github.com/syncserver/internal/syncengine"
"github.com/syncserver/internal/wol"
)
type MachineHandler struct {
db *sql.DB
db *sql.DB
engine *syncengine.Engine
}
func NewMachineHandler(db *sql.DB) *MachineHandler {
return &MachineHandler{db: db}
func NewMachineHandler(db *sql.DB, engine *syncengine.Engine) *MachineHandler {
return &MachineHandler{db: db, engine: engine}
}
var macRegex = regexp.MustCompile(`^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$`)
@@ -209,6 +211,16 @@ func (h *MachineHandler) TestWoL(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"ok": true, "sent": 3})
}
func (h *MachineHandler) Refresh(w http.ResponseWriter, r *http.Request) {
if h.engine == nil {
writeError(w, http.StatusInternalServerError, "engine not available")
return
}
go h.engine.ProbeAllMachines()
w.WriteHeader(http.StatusAccepted)
writeJSON(w, map[string]string{"status": "probing"})
}
func (h *MachineHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
+2 -1
View File
@@ -33,7 +33,7 @@ func NewServer(cfg *config.Config, db *sql.DB, engine *syncengine.Engine) *Serve
s := &Server{router: r, cfg: cfg, engine: engine}
authHandler := NewAuthHandler(db)
machineHandler := NewMachineHandler(db)
machineHandler := NewMachineHandler(db, engine)
syncPairHandler := NewSyncPairHandler(db)
jobHandler := NewJobHandler(db, engine)
sseHandler := NewSSEHandler(engine)
@@ -49,6 +49,7 @@ func NewServer(cfg *config.Config, db *sql.DB, engine *syncengine.Engine) *Serve
r.With(auth.RequireAuth).Route("/machines", func(r chi.Router) {
r.Get("/", machineHandler.List)
r.Post("/", machineHandler.Create)
r.Post("/refresh", machineHandler.Refresh)
r.Get("/{id}", machineHandler.Get)
r.Put("/{id}", machineHandler.Update)
r.Delete("/{id}", machineHandler.Delete)
+55 -6
View File
@@ -17,12 +17,13 @@ import (
)
type Engine struct {
db *sql.DB
cfg *config.Config
queue *Queue
eventBus *EventBus
mu sync.RWMutex
stopped bool
db *sql.DB
cfg *config.Config
queue *Queue
eventBus *EventBus
mu sync.RWMutex
stopped bool
lastProbeAt atomic.Int64
}
type Event struct {
@@ -333,3 +334,51 @@ func (e *Engine) CreateJob(syncPairID int64, triggerType string) (int64, error)
}
return id, nil
}
const (
probeThrottleSeconds = 10
probeTimeout = 1500 * time.Millisecond
probeMaxConcurrent = 20
)
func (e *Engine) ProbeAllMachines() {
now := time.Now().UnixNano()
last := e.lastProbeAt.Load()
if now-last < int64(probeThrottleSeconds*time.Second) {
slog.Debug("ProbeAllMachines: skipped (throttled)")
return
}
if !e.lastProbeAt.CompareAndSwap(last, now) {
return
}
machineRepo := models.NewMachineRepository(e.db)
ms, err := machineRepo.GetAll()
if err != nil {
slog.Warn("ProbeAllMachines: list failed", "error", err)
return
}
sem := make(chan struct{}, probeMaxConcurrent)
var wg sync.WaitGroup
for i := range ms {
wg.Add(1)
sem <- struct{}{}
go func(m *models.Machine) {
defer wg.Done()
defer func() { <-sem }()
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
defer cancel()
status := "offline"
if wol.IsReachable(ctx, m.Host, m.Port, probeTimeout) {
status = "online"
}
e.setMachineStatus(m.ID, status)
}(&ms[i])
}
wg.Wait()
}
+27
View File
@@ -3,6 +3,7 @@ package syncengine
import (
"context"
"net"
"sync/atomic"
"testing"
"time"
@@ -37,3 +38,29 @@ func TestIsReachableTimeout(t *testing.T) {
t.Errorf("IsReachable returned too early: %v", d)
}
}
func TestProbeThrottle(t *testing.T) {
e := &Engine{lastProbeAt: atomic.Int64{}}
e.lastProbeAt.Store(time.Now().UnixNano())
now := time.Now().UnixNano()
last := e.lastProbeAt.Load()
if now-last < int64(probeThrottleSeconds*time.Second) {
return
}
t.Error("throttle check did not run as expected")
}
func TestProbeConstants(t *testing.T) {
if probeThrottleSeconds != 10 {
t.Errorf("probeThrottleSeconds = %d, want 10", probeThrottleSeconds)
}
if probeTimeout != 1500*time.Millisecond {
t.Errorf("probeTimeout = %v, want 1500ms", probeTimeout)
}
if probeMaxConcurrent != 20 {
t.Errorf("probeMaxConcurrent = %d, want 20", probeMaxConcurrent)
}
}