Skip to content

Commit 88e9769

Browse files
authored
Merge pull request #41 from DeanJ87/feat/web-service-control
Add web-based service controls for restart and shutdown
2 parents d6e98c2 + c261624 commit 88e9769

5 files changed

Lines changed: 356 additions & 18 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,32 @@ and how to troubleshoot conflicts with ASCOM Alpaca Simulators, see
235235

236236
---
237237

238+
## Web-based service controls
239+
240+
The dashboard at `http://localhost:11111/` includes **Restart service** and
241+
**Stop service** buttons once the service is running. Both actions require a
242+
browser confirmation before executing.
243+
244+
- **Restart** — the process exits with code 1. If the service manager is
245+
configured to restart on failure (Windows Service recovery options, NSSM
246+
`AppExit` policy, or `systemd Restart=on-failure`), the process restarts
247+
automatically. If no restart policy is configured the process simply exits.
248+
- **Stop** — the process exits cleanly (code 0) and stays stopped until
249+
manually restarted. N.I.N.A. and all Alpaca clients lose safety integration
250+
while the service is stopped.
251+
252+
Both controls use `POST` requests; plain navigation links (`GET`) cannot
253+
trigger them. If the service is bound to a non-loopback address
254+
(`ALPACA_HTTP_BIND=0.0.0.0`) the dashboard displays a network-reachability
255+
warning next to the controls.
256+
257+
> **Note:** True automatic restart after a web-triggered restart is only
258+
> guaranteed when the service manager is explicitly configured for it. The
259+
> service itself cannot restart its own process; it signals intent through the
260+
> exit code.
261+
262+
---
263+
238264
## Running as a Windows service
239265

240266
### Option A: NSSM (recommended)

cmd/sqmeter-alpaca-safetymonitor/main.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"runtime"
1717
"strings"
1818
"sync"
19+
"sync/atomic"
1920
"syscall"
2021
"time"
2122

@@ -41,8 +42,10 @@ type program struct {
4142
cfgPath string
4243
uuidPath string
4344

44-
cancel context.CancelFunc
45-
stopped chan struct{}
45+
cancel context.CancelFunc
46+
stopped chan struct{}
47+
svc service.Service
48+
restartRequested atomic.Bool
4649
}
4750

4851
func (p *program) Start(s service.Service) error {
@@ -124,6 +127,16 @@ func (p *program) run(ctx context.Context, interactive bool) {
124127
return
125128
}
126129

130+
webHandler.WithServiceControl(
131+
func() {
132+
p.restartRequested.Store(true)
133+
_ = p.svc.Stop() /* #nosec G104 -- shutdown call triggered by web endpoint; error cannot be acted on in this goroutine */ //nolint:errcheck
134+
},
135+
func() {
136+
_ = p.svc.Stop() /* #nosec G104 -- shutdown call triggered by web endpoint; error cannot be acted on in this goroutine */ //nolint:errcheck
137+
},
138+
)
139+
127140
alpacaHandler := alpaca.New(cfgHolder, stateHolder, deviceUUID, version, pol.PollNow)
128141

129142
disc := discovery.New(cfg.AlpacaDiscoveryPort, cfg.AlpacaHTTPPort, logger)
@@ -278,6 +291,7 @@ func main() {
278291
fmt.Fprintln(os.Stderr, err)
279292
os.Exit(1)
280293
}
294+
prg.svc = s
281295

282296
if *svcCmd != "" {
283297
if err := service.Control(s, *svcCmd); err != nil {
@@ -304,6 +318,14 @@ func main() {
304318
fmt.Fprintln(os.Stderr, err)
305319
os.Exit(1)
306320
}
321+
322+
// Exit with code 1 when a web-triggered restart was requested so that a
323+
// service manager configured for "restart on failure" (Windows Service
324+
// recovery options, NSSM AppExit, systemd Restart=on-failure) will restart
325+
// the process. A plain stop exits with 0 and the service stays stopped.
326+
if prg.restartRequested.Load() {
327+
os.Exit(1)
328+
}
307329
}
308330

309331
// ---------- helpers ----------------------------------------------------------

internal/web/handlers.go

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ type Handler struct {
2626
startT time.Time
2727
discoveryStatus func() discovery.Status
2828
version string
29+
onRestart func()
30+
onStop func()
31+
}
32+
33+
// WithServiceControl registers callbacks for web-triggered restart and stop.
34+
// restart is called to request a graceful exit that the service manager should
35+
// restart (exit code 1); stop is called for a clean shutdown (exit code 0).
36+
// Either or both may be nil; missing callbacks return HTTP 501.
37+
func (h *Handler) WithServiceControl(restart, stop func()) {
38+
h.onRestart = restart
39+
h.onStop = stop
2940
}
3041

3142
// WithDiscovery registers a discovery status getter so the handler can expose
@@ -70,27 +81,30 @@ func (h *Handler) Register(mux *http.ServeMux) {
7081
mux.HandleFunc("PUT /config.json", h.PutConfigJSON)
7182
mux.HandleFunc("POST /api/test-sqmeter", h.TestSQMeter)
7283
mux.HandleFunc("GET /api/diagnostics", h.Diagnostics)
84+
mux.HandleFunc("POST /api/service/restart", h.ServiceRestart)
85+
mux.HandleFunc("POST /api/service/stop", h.ServiceStop)
7386
}
7487

7588
// ---------- dashboard --------------------------------------------------------
7689

7790
type DashboardData struct {
78-
SQMeterURL string
79-
HTTPPort int
80-
DiscoveryPort int
81-
Uptime string
82-
State state.EvaluatedState
83-
Connected bool
84-
Override string
85-
LastPoll string
86-
LastSuccess string
87-
HasData bool
88-
DewMargin float64
89-
WideOpen bool
90-
DiscoveryRunning bool
91-
DiscoveryHealthy bool
92-
DiscoveryError string
93-
DiscoveryHasStats bool // true when we have a status getter
91+
SQMeterURL string
92+
HTTPPort int
93+
DiscoveryPort int
94+
Uptime string
95+
State state.EvaluatedState
96+
Connected bool
97+
Override string
98+
LastPoll string
99+
LastSuccess string
100+
HasData bool
101+
DewMargin float64
102+
WideOpen bool
103+
DiscoveryRunning bool
104+
DiscoveryHealthy bool
105+
DiscoveryError string
106+
DiscoveryHasStats bool // true when we have a status getter
107+
ServiceControlEnabled bool // true when restart/stop callbacks are wired
94108
}
95109

96110
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
@@ -127,6 +141,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
127141
data.DiscoveryHealthy = ds.Healthy
128142
data.DiscoveryError = ds.LastError
129143
}
144+
data.ServiceControlEnabled = h.onRestart != nil || h.onStop != nil
130145
w.Header().Set("Content-Type", "text/html; charset=utf-8")
131146
if err := h.dash.Execute(w, data); err != nil {
132147
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
@@ -451,6 +466,66 @@ func (h *Handler) Diagnostics(w http.ResponseWriter, r *http.Request) {
451466
_ = enc.Encode(report)
452467
}
453468

469+
// ---------- service controls -------------------------------------------------
470+
471+
type serviceControlResponse struct {
472+
OK bool `json:"ok"`
473+
Message string `json:"message"`
474+
}
475+
476+
// ServiceRestart handles POST /api/service/restart.
477+
// It responds with JSON and then, after flushing the response, calls the
478+
// registered restart callback. The process exits with code 1 so that a
479+
// service manager configured for "restart on failure" (e.g. Windows Service
480+
// recovery options or NSSM) will restart it automatically. If no service
481+
// manager restart policy is configured the process simply exits.
482+
func (h *Handler) ServiceRestart(w http.ResponseWriter, r *http.Request) {
483+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
484+
enc := json.NewEncoder(w)
485+
if h.onRestart == nil {
486+
w.WriteHeader(http.StatusNotImplemented)
487+
_ = enc.Encode(serviceControlResponse{OK: false, Message: "restart not available"}) /* #nosec G104 -- writing JSON to http.ResponseWriter; error indicates broken connection, nothing to act on */ //nolint:errcheck
488+
return
489+
}
490+
_ = enc.Encode(serviceControlResponse{ /* #nosec G104 -- same as above */ //nolint:errcheck
491+
OK: true,
492+
Message: "restart initiated; service will exit — restart depends on service manager configuration",
493+
})
494+
if f, ok := w.(http.Flusher); ok {
495+
f.Flush()
496+
}
497+
go func() {
498+
time.Sleep(200 * time.Millisecond)
499+
h.onRestart()
500+
}()
501+
}
502+
503+
// ServiceStop handles POST /api/service/stop.
504+
// It responds with JSON and then calls the registered stop callback after
505+
// flushing the response. The process exits cleanly (code 0); the service will
506+
// remain stopped until manually restarted. N.I.N.A. and other Alpaca clients
507+
// will lose safety integration until the service is running again.
508+
func (h *Handler) ServiceStop(w http.ResponseWriter, r *http.Request) {
509+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
510+
enc := json.NewEncoder(w)
511+
if h.onStop == nil {
512+
w.WriteHeader(http.StatusNotImplemented)
513+
_ = enc.Encode(serviceControlResponse{OK: false, Message: "stop not available"}) /* #nosec G104 -- writing JSON to http.ResponseWriter; error indicates broken connection, nothing to act on */ //nolint:errcheck
514+
return
515+
}
516+
_ = enc.Encode(serviceControlResponse{ /* #nosec G104 -- same as above */ //nolint:errcheck
517+
OK: true,
518+
Message: "stop initiated; N.I.N.A./Alpaca safety integration will be unavailable until the service is restarted",
519+
})
520+
if f, ok := w.(http.Flusher); ok {
521+
f.Flush()
522+
}
523+
go func() {
524+
time.Sleep(200 * time.Millisecond)
525+
h.onStop()
526+
}()
527+
}
528+
454529
// ---------- SQMeter connection test -----------------------------------------
455530

456531
// TestSQMeter handles POST /api/test-sqmeter.

internal/web/handlers_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,162 @@ func TestPostSetup_InvalidOptionalFloat_Ignores(t *testing.T) {
796796
}
797797
}
798798

799+
// ---------- POST /api/service/restart & /api/service/stop -------------------
800+
801+
func TestServiceRestart_WithCallback_Returns200(t *testing.T) {
802+
h, _, _ := newTestWebHandler(t, true, safeEv())
803+
called := make(chan struct{}, 1)
804+
h.WithServiceControl(func() { called <- struct{}{} }, nil)
805+
806+
w := serve(t, h, http.MethodPost, "/api/service/restart", "")
807+
if w.Code != http.StatusOK {
808+
t.Fatalf("POST /api/service/restart: want 200, got %d — body: %s", w.Code, w.Body.String())
809+
}
810+
var resp struct {
811+
OK bool `json:"ok"`
812+
Message string `json:"message"`
813+
}
814+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
815+
t.Fatalf("POST /api/service/restart: invalid JSON: %v", err)
816+
}
817+
if !resp.OK {
818+
t.Errorf("POST /api/service/restart: want ok=true, got false; message: %s", resp.Message)
819+
}
820+
if resp.Message == "" {
821+
t.Error("POST /api/service/restart: want non-empty message")
822+
}
823+
}
824+
825+
func TestServiceStop_WithCallback_Returns200(t *testing.T) {
826+
h, _, _ := newTestWebHandler(t, true, safeEv())
827+
called := make(chan struct{}, 1)
828+
h.WithServiceControl(nil, func() { called <- struct{}{} })
829+
830+
w := serve(t, h, http.MethodPost, "/api/service/stop", "")
831+
if w.Code != http.StatusOK {
832+
t.Fatalf("POST /api/service/stop: want 200, got %d — body: %s", w.Code, w.Body.String())
833+
}
834+
var resp struct {
835+
OK bool `json:"ok"`
836+
Message string `json:"message"`
837+
}
838+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
839+
t.Fatalf("POST /api/service/stop: invalid JSON: %v", err)
840+
}
841+
if !resp.OK {
842+
t.Errorf("POST /api/service/stop: want ok=true, got false; message: %s", resp.Message)
843+
}
844+
if resp.Message == "" {
845+
t.Error("POST /api/service/stop: want non-empty message")
846+
}
847+
}
848+
849+
func TestServiceRestart_WithoutCallback_Returns501(t *testing.T) {
850+
h, _, _ := newTestWebHandler(t, true, safeEv())
851+
// no WithServiceControl call
852+
853+
w := serve(t, h, http.MethodPost, "/api/service/restart", "")
854+
if w.Code != http.StatusNotImplemented {
855+
t.Errorf("POST /api/service/restart without callback: want 501, got %d", w.Code)
856+
}
857+
var resp struct {
858+
OK bool `json:"ok"`
859+
}
860+
json.NewDecoder(w.Body).Decode(&resp) //nolint:errcheck
861+
if resp.OK {
862+
t.Error("POST /api/service/restart without callback: want ok=false")
863+
}
864+
}
865+
866+
func TestServiceStop_WithoutCallback_Returns501(t *testing.T) {
867+
h, _, _ := newTestWebHandler(t, true, safeEv())
868+
// no WithServiceControl call
869+
870+
w := serve(t, h, http.MethodPost, "/api/service/stop", "")
871+
if w.Code != http.StatusNotImplemented {
872+
t.Errorf("POST /api/service/stop without callback: want 501, got %d", w.Code)
873+
}
874+
var resp struct {
875+
OK bool `json:"ok"`
876+
}
877+
json.NewDecoder(w.Body).Decode(&resp) //nolint:errcheck
878+
if resp.OK {
879+
t.Error("POST /api/service/stop without callback: want ok=false")
880+
}
881+
}
882+
883+
func TestServiceRestart_GET_DoesNotTriggerAction(t *testing.T) {
884+
// GET /api/service/restart falls through to the Dashboard catch-all ("GET /")
885+
// and never calls the service callback. This is the intended safety behaviour:
886+
// service actions require an explicit POST.
887+
h, _, _ := newTestWebHandler(t, true, safeEv())
888+
triggered := false
889+
h.WithServiceControl(func() { triggered = true }, nil)
890+
891+
w := serve(t, h, http.MethodGet, "/api/service/restart", "")
892+
if w.Code != http.StatusOK {
893+
t.Errorf("GET /api/service/restart: want 200 (dashboard fallback), got %d", w.Code)
894+
}
895+
if triggered {
896+
t.Error("GET /api/service/restart: must not trigger restart callback")
897+
}
898+
// Must not return service-control JSON
899+
if strings.Contains(w.Body.String(), `"ok"`) {
900+
t.Error("GET /api/service/restart: must not return service-control JSON")
901+
}
902+
}
903+
904+
func TestServiceStop_GET_DoesNotTriggerAction(t *testing.T) {
905+
h, _, _ := newTestWebHandler(t, true, safeEv())
906+
triggered := false
907+
h.WithServiceControl(nil, func() { triggered = true })
908+
909+
w := serve(t, h, http.MethodGet, "/api/service/stop", "")
910+
if w.Code != http.StatusOK {
911+
t.Errorf("GET /api/service/stop: want 200 (dashboard fallback), got %d", w.Code)
912+
}
913+
if triggered {
914+
t.Error("GET /api/service/stop: must not trigger stop callback")
915+
}
916+
if strings.Contains(w.Body.String(), `"ok"`) {
917+
t.Error("GET /api/service/stop: must not return service-control JSON")
918+
}
919+
}
920+
921+
func TestDashboard_ServiceControlsVisible_WhenWired(t *testing.T) {
922+
h, _, _ := newTestWebHandler(t, true, safeEv())
923+
h.WithServiceControl(func() {}, func() {})
924+
925+
w := serve(t, h, http.MethodGet, "/", "")
926+
if w.Code != http.StatusOK {
927+
t.Fatalf("dashboard: want 200, got %d", w.Code)
928+
}
929+
body := w.Body.String()
930+
if !strings.Contains(body, "service-controls") {
931+
t.Error("dashboard: expected service-controls section when callbacks wired")
932+
}
933+
if !strings.Contains(body, "Restart service") {
934+
t.Error("dashboard: expected 'Restart service' button")
935+
}
936+
if !strings.Contains(body, "Stop service") {
937+
t.Error("dashboard: expected 'Stop service' button")
938+
}
939+
}
940+
941+
func TestDashboard_ServiceControlsHidden_WhenNotWired(t *testing.T) {
942+
h, _, _ := newTestWebHandler(t, true, safeEv())
943+
// no WithServiceControl
944+
945+
w := serve(t, h, http.MethodGet, "/", "")
946+
if w.Code != http.StatusOK {
947+
t.Fatalf("dashboard: want 200, got %d", w.Code)
948+
}
949+
body := w.Body.String()
950+
if strings.Contains(body, "service-controls") {
951+
t.Error("dashboard: service-controls section should be absent when not wired")
952+
}
953+
}
954+
799955
func TestGetSetup_DisplaysOptionalFloats(t *testing.T) {
800956
h, cfgHolder, _ := newTestWebHandler(t, true, safeEv())
801957

0 commit comments

Comments
 (0)