server.go raw
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "time"
10
11 "next.orly.dev/pkg/lol/chk"
12 "next.orly.dev/pkg/lol/log"
13 )
14
15 // AdminServer provides HTTP endpoints for managing the launcher.
16 type AdminServer struct {
17 cfg *Config
18 supervisor *Supervisor
19 updater *Updater
20 auth *AuthMiddleware
21 server *http.Server
22 startTime time.Time
23 }
24
25 // NewAdminServer creates a new admin HTTP server.
26 func NewAdminServer(cfg *Config, supervisor *Supervisor) *AdminServer {
27 return &AdminServer{
28 cfg: cfg,
29 supervisor: supervisor,
30 updater: NewUpdater(cfg.BinDir),
31 auth: NewAuthMiddleware(cfg.AdminOwners),
32 startTime: time.Now(),
33 }
34 }
35
36 // Start starts the admin HTTP server.
37 func (s *AdminServer) Start(ctx context.Context) error {
38 mux := http.NewServeMux()
39
40 // Public endpoints
41 mux.HandleFunc("/admin", s.serveUI)
42 mux.HandleFunc("/admin/", s.serveUI)
43
44 // Authenticated API endpoints
45 mux.HandleFunc("/api/status", s.auth.RequireAuth(s.handleStatus))
46 mux.HandleFunc("/api/config", s.auth.RequireAuth(s.handleConfig))
47 mux.HandleFunc("/api/binaries", s.auth.RequireAuth(s.handleBinaries))
48 mux.HandleFunc("/api/update", s.auth.RequireAuth(s.handleUpdate))
49 mux.HandleFunc("/api/releases", s.auth.RequireAuth(s.handleReleases))
50 mux.HandleFunc("/api/restart", s.auth.RequireAuth(s.handleRestart))
51 mux.HandleFunc("/api/restart-service", s.auth.RequireAuth(s.handleRestartService))
52 mux.HandleFunc("/api/rollback", s.auth.RequireAuth(s.handleRollback))
53 mux.HandleFunc("/api/start-services", s.auth.RequireAuth(s.handleStartServices))
54 mux.HandleFunc("/api/stop-services", s.auth.RequireAuth(s.handleStopServices))
55 mux.HandleFunc("/api/start-service", s.auth.RequireAuth(s.handleStartService))
56 mux.HandleFunc("/api/stop-service", s.auth.RequireAuth(s.handleStopService))
57
58 addr := fmt.Sprintf(":%d", s.cfg.AdminPort)
59 s.server = &http.Server{
60 Addr: addr,
61 Handler: mux,
62 }
63
64 log.I.F("starting admin server on %s", addr)
65
66 go func() {
67 <-ctx.Done()
68 shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
69 defer cancel()
70 s.server.Shutdown(shutdownCtx)
71 }()
72
73 return s.server.ListenAndServe()
74 }
75
76 // StatusResponse is the response for GET /api/status
77 type StatusResponse struct {
78 Version string `json:"version"`
79 Uptime string `json:"uptime"`
80 ServicesRunning bool `json:"services_running"`
81 Processes []ProcessStatus `json:"processes"`
82 }
83
84 // ProcessStatus represents the status of a single managed process.
85 type ProcessStatus struct {
86 Name string `json:"name"`
87 Binary string `json:"binary"`
88 Version string `json:"version"`
89 Status string `json:"status"` // running, stopped, disabled
90 Enabled bool `json:"enabled"`
91 Category string `json:"category"` // database, acl, sync, certs, relay
92 Description string `json:"description"`
93 PID int `json:"pid"`
94 Restarts int `json:"restarts"`
95 StartedAt string `json:"started_at,omitempty"`
96 }
97
98 func (s *AdminServer) handleStatus(w http.ResponseWriter, r *http.Request) {
99 if r.Method != http.MethodGet {
100 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
101 return
102 }
103
104 uptime := time.Since(s.startTime).Round(time.Second).String()
105 processes := s.supervisor.GetProcessStatuses()
106
107 response := StatusResponse{
108 Version: s.updater.CurrentVersion(),
109 Uptime: uptime,
110 ServicesRunning: s.supervisor.IsRunning(),
111 Processes: processes,
112 }
113
114 w.Header().Set("Content-Type", "application/json")
115 json.NewEncoder(w).Encode(response)
116 }
117
118 // ConfigResponse is the response for GET /api/config
119 type ConfigResponse struct {
120 DBBackend string `json:"db_backend"`
121 DBBinary string `json:"db_binary"`
122 RelayBinary string `json:"relay_binary"`
123 ACLBinary string `json:"acl_binary"`
124 DBListen string `json:"db_listen"`
125 ACLListen string `json:"acl_listen"`
126 ACLEnabled bool `json:"acl_enabled"`
127 ACLMode string `json:"acl_mode"`
128 DataDir string `json:"data_dir"`
129 LogLevel string `json:"log_level"`
130 DistributedSyncEnabled bool `json:"distributed_sync_enabled"`
131 ClusterSyncEnabled bool `json:"cluster_sync_enabled"`
132 RelayGroupEnabled bool `json:"relay_group_enabled"`
133 NegentropyEnabled bool `json:"negentropy_enabled"`
134 AdminOwners []string `json:"admin_owners"`
135 BinDir string `json:"bin_dir"`
136
137 // Bitcoin node (nits)
138 NitsEnabled bool `json:"nits_enabled"`
139 NitsBinary string `json:"nits_binary"`
140 NitsListen string `json:"nits_listen"`
141 NitsRPCPort int `json:"nits_rpc_port"`
142 NitsDataDir string `json:"nits_data_dir"`
143 NitsPruneMB int `json:"nits_prune_mb"`
144 NitsNetwork string `json:"nits_network"`
145
146 // Lightning node (luk)
147 LukEnabled bool `json:"luk_enabled"`
148 LukBinary string `json:"luk_binary"`
149 LukDataDir string `json:"luk_data_dir"`
150 LukRPCListen string `json:"luk_rpc_listen"`
151 LukPeerListen string `json:"luk_peer_listen"`
152
153 // Wallet (strela)
154 StrelaEnabled bool `json:"strela_enabled"`
155 StrelaBinary string `json:"strela_binary"`
156 StrelaPort int `json:"strela_port"`
157 StrelaDataDir string `json:"strela_data_dir"`
158 }
159
160 func (s *AdminServer) handleConfig(w http.ResponseWriter, r *http.Request) {
161 switch r.Method {
162 case http.MethodGet:
163 s.handleGetConfig(w, r)
164 case http.MethodPost:
165 s.handleSetConfig(w, r)
166 default:
167 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
168 }
169 }
170
171 func (s *AdminServer) handleGetConfig(w http.ResponseWriter, r *http.Request) {
172 response := ConfigResponse{
173 DBBackend: s.cfg.DBBackend,
174 DBBinary: s.cfg.DBBinary,
175 RelayBinary: s.cfg.RelayBinary,
176 ACLBinary: s.cfg.ACLBinary,
177 DBListen: s.cfg.DBListen,
178 ACLListen: s.cfg.ACLListen,
179 ACLEnabled: s.cfg.ACLEnabled,
180 ACLMode: s.cfg.ACLMode,
181 DataDir: s.cfg.DataDir,
182 LogLevel: s.cfg.LogLevel,
183 DistributedSyncEnabled: s.cfg.DistributedSyncEnabled,
184 ClusterSyncEnabled: s.cfg.ClusterSyncEnabled,
185 RelayGroupEnabled: s.cfg.RelayGroupEnabled,
186 NegentropyEnabled: s.cfg.NegentropyEnabled,
187 AdminOwners: s.auth.Owners(),
188 BinDir: s.cfg.BinDir,
189 NitsEnabled: s.cfg.NitsEnabled,
190 NitsBinary: s.cfg.NitsBinary,
191 NitsListen: s.cfg.NitsListen,
192 NitsRPCPort: s.cfg.NitsRPCPort,
193 NitsDataDir: s.cfg.NitsDataDir,
194 NitsPruneMB: s.cfg.NitsPruneMB,
195 NitsNetwork: s.cfg.NitsNetwork,
196 LukEnabled: s.cfg.LukEnabled,
197 LukBinary: s.cfg.LukBinary,
198 LukDataDir: s.cfg.LukDataDir,
199 LukRPCListen: s.cfg.LukRPCListen,
200 LukPeerListen: s.cfg.LukPeerListen,
201 StrelaEnabled: s.cfg.StrelaEnabled,
202 StrelaBinary: s.cfg.StrelaBinary,
203 StrelaPort: s.cfg.StrelaPort,
204 StrelaDataDir: s.cfg.StrelaDataDir,
205 }
206
207 w.Header().Set("Content-Type", "application/json")
208 json.NewEncoder(w).Encode(response)
209 }
210
211 // SetConfigRequest is the request body for POST /api/config
212 type SetConfigRequest struct {
213 DBBackend string `json:"db_backend,omitempty"`
214 DBBinary string `json:"db_binary,omitempty"`
215 RelayBinary string `json:"relay_binary,omitempty"`
216 ACLBinary string `json:"acl_binary,omitempty"`
217 DBListen string `json:"db_listen,omitempty"`
218 ACLListen string `json:"acl_listen,omitempty"`
219 ACLEnabled *bool `json:"acl_enabled,omitempty"`
220 ACLMode string `json:"acl_mode,omitempty"`
221 DataDir string `json:"data_dir,omitempty"`
222 LogLevel string `json:"log_level,omitempty"`
223 AdminPort *int `json:"admin_port,omitempty"`
224 AdminOwners []string `json:"admin_owners,omitempty"`
225 BinDir string `json:"bin_dir,omitempty"`
226 DistributedSyncEnabled *bool `json:"distributed_sync_enabled,omitempty"`
227 ClusterSyncEnabled *bool `json:"cluster_sync_enabled,omitempty"`
228 RelayGroupEnabled *bool `json:"relay_group_enabled,omitempty"`
229 NegentropyEnabled *bool `json:"negentropy_enabled,omitempty"`
230
231 // Bitcoin node (nits)
232 NitsEnabled *bool `json:"nits_enabled,omitempty"`
233 NitsBinary string `json:"nits_binary,omitempty"`
234 NitsDataDir string `json:"nits_data_dir,omitempty"`
235 NitsPruneMB *int `json:"nits_prune_mb,omitempty"`
236 NitsNetwork string `json:"nits_network,omitempty"`
237
238 // Lightning node (luk)
239 LukEnabled *bool `json:"luk_enabled,omitempty"`
240 LukBinary string `json:"luk_binary,omitempty"`
241 LukDataDir string `json:"luk_data_dir,omitempty"`
242 LukRPCListen string `json:"luk_rpc_listen,omitempty"`
243 LukPeerListen string `json:"luk_peer_listen,omitempty"`
244
245 // Wallet (strela)
246 StrelaEnabled *bool `json:"strela_enabled,omitempty"`
247 StrelaBinary string `json:"strela_binary,omitempty"`
248 StrelaPort *int `json:"strela_port,omitempty"`
249 StrelaDataDir string `json:"strela_data_dir,omitempty"`
250 }
251
252 // SetConfigResponse is the response for POST /api/config
253 type SetConfigResponse struct {
254 Success bool `json:"success"`
255 Message string `json:"message"`
256 RestartNeeded bool `json:"restart_needed"`
257 ConfigFilePath string `json:"config_file_path"`
258 }
259
260 func (s *AdminServer) handleSetConfig(w http.ResponseWriter, r *http.Request) {
261 var req SetConfigRequest
262 if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) {
263 http.Error(w, "Invalid request body", http.StatusBadRequest)
264 return
265 }
266
267 // Load existing config file or create new
268 cf, err := loadConfigFile()
269 if chk.E(err) {
270 cf = &ConfigFile{}
271 }
272
273 // Update only fields that were provided
274 if req.DBBackend != "" {
275 cf.DBBackend = req.DBBackend
276 }
277 if req.DBBinary != "" {
278 cf.DBBinary = req.DBBinary
279 }
280 if req.RelayBinary != "" {
281 cf.RelayBinary = req.RelayBinary
282 }
283 if req.ACLBinary != "" {
284 cf.ACLBinary = req.ACLBinary
285 }
286 if req.DBListen != "" {
287 cf.DBListen = req.DBListen
288 }
289 if req.ACLListen != "" {
290 cf.ACLListen = req.ACLListen
291 }
292 if req.ACLEnabled != nil {
293 cf.ACLEnabled = req.ACLEnabled
294 }
295 if req.ACLMode != "" {
296 cf.ACLMode = req.ACLMode
297 }
298 if req.DataDir != "" {
299 cf.DataDir = req.DataDir
300 }
301 if req.LogLevel != "" {
302 cf.LogLevel = req.LogLevel
303 }
304 if req.AdminPort != nil {
305 cf.AdminPort = req.AdminPort
306 }
307 if req.AdminOwners != nil {
308 cf.AdminOwners = req.AdminOwners
309 }
310 if req.BinDir != "" {
311 cf.BinDir = req.BinDir
312 }
313 if req.DistributedSyncEnabled != nil {
314 cf.DistributedSyncEnabled = req.DistributedSyncEnabled
315 }
316 if req.ClusterSyncEnabled != nil {
317 cf.ClusterSyncEnabled = req.ClusterSyncEnabled
318 }
319 if req.RelayGroupEnabled != nil {
320 cf.RelayGroupEnabled = req.RelayGroupEnabled
321 }
322 if req.NegentropyEnabled != nil {
323 cf.NegentropyEnabled = req.NegentropyEnabled
324 }
325 if req.NitsEnabled != nil {
326 cf.NitsEnabled = req.NitsEnabled
327 }
328 if req.NitsBinary != "" {
329 cf.NitsBinary = req.NitsBinary
330 }
331 if req.NitsDataDir != "" {
332 cf.NitsDataDir = req.NitsDataDir
333 }
334 if req.NitsPruneMB != nil {
335 cf.NitsPruneMB = req.NitsPruneMB
336 }
337 if req.NitsNetwork != "" {
338 cf.NitsNetwork = req.NitsNetwork
339 }
340 if req.LukEnabled != nil {
341 cf.LukEnabled = req.LukEnabled
342 }
343 if req.LukBinary != "" {
344 cf.LukBinary = req.LukBinary
345 }
346 if req.LukDataDir != "" {
347 cf.LukDataDir = req.LukDataDir
348 }
349 if req.LukRPCListen != "" {
350 cf.LukRPCListen = req.LukRPCListen
351 }
352 if req.LukPeerListen != "" {
353 cf.LukPeerListen = req.LukPeerListen
354 }
355 if req.StrelaEnabled != nil {
356 cf.StrelaEnabled = req.StrelaEnabled
357 }
358 if req.StrelaBinary != "" {
359 cf.StrelaBinary = req.StrelaBinary
360 }
361 if req.StrelaPort != nil {
362 cf.StrelaPort = req.StrelaPort
363 }
364 if req.StrelaDataDir != "" {
365 cf.StrelaDataDir = req.StrelaDataDir
366 }
367
368 // Save to file
369 if err := SaveConfigFile(cf); chk.E(err) {
370 response := SetConfigResponse{
371 Success: false,
372 Message: fmt.Sprintf("Failed to save config: %v", err),
373 }
374 w.Header().Set("Content-Type", "application/json")
375 w.WriteHeader(http.StatusInternalServerError)
376 json.NewEncoder(w).Encode(response)
377 return
378 }
379
380 // Update auth middleware if owners changed
381 if req.AdminOwners != nil {
382 for _, owner := range s.auth.Owners() {
383 s.auth.RemoveOwner(owner)
384 }
385 for _, owner := range req.AdminOwners {
386 s.auth.AddOwner(owner)
387 }
388 }
389
390 response := SetConfigResponse{
391 Success: true,
392 Message: "Configuration saved. Restart required for most changes to take effect.",
393 RestartNeeded: true,
394 ConfigFilePath: configFilePath(),
395 }
396
397 w.Header().Set("Content-Type", "application/json")
398 json.NewEncoder(w).Encode(response)
399 }
400
401 // BinariesResponse is the response for GET /api/binaries
402 type BinariesResponse struct {
403 CurrentVersion string `json:"current_version"`
404 AvailableVersions []VersionInfo `json:"available_versions"`
405 }
406
407 func (s *AdminServer) handleBinaries(w http.ResponseWriter, r *http.Request) {
408 if r.Method != http.MethodGet {
409 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
410 return
411 }
412
413 response := BinariesResponse{
414 CurrentVersion: s.updater.CurrentVersion(),
415 AvailableVersions: s.updater.ListVersions(),
416 }
417
418 w.Header().Set("Content-Type", "application/json")
419 json.NewEncoder(w).Encode(response)
420 }
421
422 // ReleasesResponse is the response for GET /api/releases
423 type ReleasesResponse struct {
424 Releases []ReleaseInfo `json:"releases"`
425 }
426
427 // ReleaseInfo represents a single release/tag
428 type ReleaseInfo struct {
429 Tag string `json:"tag"`
430 Message string `json:"message"`
431 }
432
433 const tagsAPIURL = "https://git.nostrdev.com/api/v1/repos/mleku/next.orly.dev/tags"
434
435 func (s *AdminServer) handleReleases(w http.ResponseWriter, r *http.Request) {
436 if r.Method != http.MethodGet {
437 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
438 return
439 }
440
441 // Fetch tags from upstream
442 client := &http.Client{Timeout: 10 * time.Second}
443 resp, err := client.Get(tagsAPIURL)
444 if err != nil {
445 log.E.F("failed to fetch tags: %v", err)
446 http.Error(w, "Failed to fetch releases", http.StatusBadGateway)
447 return
448 }
449 defer resp.Body.Close()
450
451 if resp.StatusCode != http.StatusOK {
452 http.Error(w, "Failed to fetch releases", http.StatusBadGateway)
453 return
454 }
455
456 body, err := io.ReadAll(resp.Body)
457 if err != nil {
458 http.Error(w, "Failed to read response", http.StatusInternalServerError)
459 return
460 }
461
462 // Parse the tags response
463 var tags []struct {
464 Name string `json:"name"`
465 Message string `json:"message"`
466 }
467 if err := json.Unmarshal(body, &tags); chk.E(err) {
468 http.Error(w, "Failed to parse response", http.StatusInternalServerError)
469 return
470 }
471
472 // Filter and transform to our response format
473 var releases []ReleaseInfo
474 for _, tag := range tags {
475 if len(tag.Name) > 0 && tag.Name[0] == 'v' {
476 msg := tag.Message
477 // Get first line only
478 for i, c := range msg {
479 if c == '\n' {
480 msg = msg[:i]
481 break
482 }
483 }
484 releases = append(releases, ReleaseInfo{
485 Tag: tag.Name,
486 Message: msg,
487 })
488 }
489 if len(releases) >= 15 {
490 break
491 }
492 }
493
494 response := ReleasesResponse{Releases: releases}
495 w.Header().Set("Content-Type", "application/json")
496 json.NewEncoder(w).Encode(response)
497 }
498
499 // UpdateRequest is the request body for POST /api/update
500 type UpdateRequest struct {
501 Version string `json:"version"`
502 URLs map[string]string `json:"urls"` // binary name -> download URL
503 }
504
505 // UpdateResponse is the response for POST /api/update
506 type UpdateResponse struct {
507 Success bool `json:"success"`
508 Message string `json:"message"`
509 Version string `json:"version"`
510 DownloadedFiles []string `json:"downloaded_files"`
511 }
512
513 func (s *AdminServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
514 if r.Method != http.MethodPost {
515 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
516 return
517 }
518
519 var req UpdateRequest
520 if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) {
521 http.Error(w, "Invalid request body", http.StatusBadRequest)
522 return
523 }
524
525 if req.Version == "" {
526 http.Error(w, "Version is required", http.StatusBadRequest)
527 return
528 }
529
530 if len(req.URLs) == 0 {
531 http.Error(w, "At least one binary URL is required", http.StatusBadRequest)
532 return
533 }
534
535 // Perform the update
536 downloadedFiles, err := s.updater.Update(req.Version, req.URLs)
537 if chk.E(err) {
538 response := UpdateResponse{
539 Success: false,
540 Message: err.Error(),
541 Version: req.Version,
542 }
543 w.Header().Set("Content-Type", "application/json")
544 w.WriteHeader(http.StatusInternalServerError)
545 json.NewEncoder(w).Encode(response)
546 return
547 }
548
549 response := UpdateResponse{
550 Success: true,
551 Message: fmt.Sprintf("Successfully updated to version %s", req.Version),
552 Version: req.Version,
553 DownloadedFiles: downloadedFiles,
554 }
555
556 w.Header().Set("Content-Type", "application/json")
557 json.NewEncoder(w).Encode(response)
558 }
559
560 // RestartResponse is the response for POST /api/restart
561 type RestartResponse struct {
562 Success bool `json:"success"`
563 Message string `json:"message"`
564 }
565
566 func (s *AdminServer) handleRestart(w http.ResponseWriter, r *http.Request) {
567 if r.Method != http.MethodPost {
568 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
569 return
570 }
571
572 // Signal supervisor to restart all processes
573 go func() {
574 if err := s.supervisor.RestartAll(); chk.E(err) {
575 log.E.F("restart failed: %v", err)
576 }
577 }()
578
579 response := RestartResponse{
580 Success: true,
581 Message: "Restart initiated",
582 }
583
584 w.Header().Set("Content-Type", "application/json")
585 json.NewEncoder(w).Encode(response)
586 }
587
588 // RestartServiceRequest is the request body for POST /api/restart-service
589 type RestartServiceRequest struct {
590 Service string `json:"service"`
591 }
592
593 // RestartServiceResponse is the response for POST /api/restart-service
594 type RestartServiceResponse struct {
595 Success bool `json:"success"`
596 Message string `json:"message"`
597 Restarted []string `json:"restarted"`
598 }
599
600 func (s *AdminServer) handleRestartService(w http.ResponseWriter, r *http.Request) {
601 if r.Method != http.MethodPost {
602 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
603 return
604 }
605
606 var req RestartServiceRequest
607 if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) {
608 http.Error(w, "Invalid request body", http.StatusBadRequest)
609 return
610 }
611
612 if req.Service == "" {
613 http.Error(w, "Service name is required", http.StatusBadRequest)
614 return
615 }
616
617 // Map binary names to service names
618 serviceName := req.Service
619 switch req.Service {
620 case "orly-db-badger", "orly-db-neo4j":
621 serviceName = "orly-db"
622 case "orly-acl-follows", "orly-acl-managed", "orly-acl-curation":
623 serviceName = "orly-acl"
624 }
625
626 // Perform the restart in a goroutine to avoid blocking
627 go func() {
628 if restarted, err := s.supervisor.RestartService(serviceName); chk.E(err) {
629 log.E.F("restart service %s failed: %v", serviceName, err)
630 } else {
631 log.I.F("restart service completed: %v", restarted)
632 }
633 }()
634
635 response := RestartServiceResponse{
636 Success: true,
637 Message: fmt.Sprintf("Restart of %s initiated", serviceName),
638 }
639
640 w.Header().Set("Content-Type", "application/json")
641 json.NewEncoder(w).Encode(response)
642 }
643
644 // RollbackResponse is the response for POST /api/rollback
645 type RollbackResponse struct {
646 Success bool `json:"success"`
647 Message string `json:"message"`
648 PreviousVersion string `json:"previous_version"`
649 CurrentVersion string `json:"current_version"`
650 }
651
652 func (s *AdminServer) handleRollback(w http.ResponseWriter, r *http.Request) {
653 if r.Method != http.MethodPost {
654 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
655 return
656 }
657
658 previousVersion := s.updater.CurrentVersion()
659
660 if err := s.updater.Rollback(); chk.E(err) {
661 response := RollbackResponse{
662 Success: false,
663 Message: err.Error(),
664 }
665 w.Header().Set("Content-Type", "application/json")
666 w.WriteHeader(http.StatusInternalServerError)
667 json.NewEncoder(w).Encode(response)
668 return
669 }
670
671 response := RollbackResponse{
672 Success: true,
673 Message: "Rollback successful - restart required to apply",
674 PreviousVersion: previousVersion,
675 CurrentVersion: s.updater.CurrentVersion(),
676 }
677
678 w.Header().Set("Content-Type", "application/json")
679 json.NewEncoder(w).Encode(response)
680 }
681
682 // StartServicesResponse is the response for POST /api/start-services
683 type StartServicesResponse struct {
684 Success bool `json:"success"`
685 Message string `json:"message"`
686 }
687
688 func (s *AdminServer) handleStartServices(w http.ResponseWriter, r *http.Request) {
689 if r.Method != http.MethodPost {
690 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
691 return
692 }
693
694 // Check if services are already running
695 if s.supervisor.IsRunning() {
696 response := StartServicesResponse{
697 Success: false,
698 Message: "Services are already running",
699 }
700 w.Header().Set("Content-Type", "application/json")
701 w.WriteHeader(http.StatusConflict)
702 json.NewEncoder(w).Encode(response)
703 return
704 }
705
706 // Start services in a goroutine
707 go func() {
708 if err := s.supervisor.Start(); chk.E(err) {
709 log.E.F("failed to start services: %v", err)
710 }
711 }()
712
713 response := StartServicesResponse{
714 Success: true,
715 Message: "Services starting...",
716 }
717
718 w.Header().Set("Content-Type", "application/json")
719 json.NewEncoder(w).Encode(response)
720 }
721
722 // StopServicesResponse is the response for POST /api/stop-services
723 type StopServicesResponse struct {
724 Success bool `json:"success"`
725 Message string `json:"message"`
726 }
727
728 func (s *AdminServer) handleStopServices(w http.ResponseWriter, r *http.Request) {
729 if r.Method != http.MethodPost {
730 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
731 return
732 }
733
734 // Check if services are running
735 if !s.supervisor.IsRunning() {
736 response := StopServicesResponse{
737 Success: false,
738 Message: "Services are not running",
739 }
740 w.Header().Set("Content-Type", "application/json")
741 w.WriteHeader(http.StatusConflict)
742 json.NewEncoder(w).Encode(response)
743 return
744 }
745
746 // Stop services in a goroutine
747 go func() {
748 if err := s.supervisor.Stop(); chk.E(err) {
749 log.E.F("failed to stop services: %v", err)
750 }
751 }()
752
753 response := StopServicesResponse{
754 Success: true,
755 Message: "Services stopping...",
756 }
757
758 w.Header().Set("Content-Type", "application/json")
759 json.NewEncoder(w).Encode(response)
760 }
761
762 // StartServiceRequest is the request body for POST /api/start-service
763 type StartServiceRequest struct {
764 Service string `json:"service"`
765 }
766
767 // StartServiceResponse is the response for POST /api/start-service
768 type StartServiceResponse struct {
769 Success bool `json:"success"`
770 Message string `json:"message"`
771 }
772
773 func (s *AdminServer) handleStartService(w http.ResponseWriter, r *http.Request) {
774 if r.Method != http.MethodPost {
775 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
776 return
777 }
778
779 var req StartServiceRequest
780 if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) {
781 http.Error(w, "Invalid request body", http.StatusBadRequest)
782 return
783 }
784
785 if req.Service == "" {
786 http.Error(w, "Service name is required", http.StatusBadRequest)
787 return
788 }
789
790 // Start the service
791 go func() {
792 if err := s.supervisor.StartService(req.Service); chk.E(err) {
793 log.E.F("start service %s failed: %v", req.Service, err)
794 } else {
795 log.I.F("started service: %s", req.Service)
796 }
797 }()
798
799 response := StartServiceResponse{
800 Success: true,
801 Message: fmt.Sprintf("Start of %s initiated", req.Service),
802 }
803
804 w.Header().Set("Content-Type", "application/json")
805 json.NewEncoder(w).Encode(response)
806 }
807
808 // StopServiceRequest is the request body for POST /api/stop-service
809 type StopServiceRequest struct {
810 Service string `json:"service"`
811 }
812
813 // StopServiceResponse is the response for POST /api/stop-service
814 type StopServiceResponse struct {
815 Success bool `json:"success"`
816 Message string `json:"message"`
817 }
818
819 func (s *AdminServer) handleStopService(w http.ResponseWriter, r *http.Request) {
820 if r.Method != http.MethodPost {
821 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
822 return
823 }
824
825 var req StopServiceRequest
826 if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) {
827 http.Error(w, "Invalid request body", http.StatusBadRequest)
828 return
829 }
830
831 if req.Service == "" {
832 http.Error(w, "Service name is required", http.StatusBadRequest)
833 return
834 }
835
836 // Stop the service
837 go func() {
838 if err := s.supervisor.StopService(req.Service); chk.E(err) {
839 log.E.F("stop service %s failed: %v", req.Service, err)
840 } else {
841 log.I.F("stopped service: %s", req.Service)
842 }
843 }()
844
845 response := StopServiceResponse{
846 Success: true,
847 Message: fmt.Sprintf("Stop of %s initiated", req.Service),
848 }
849
850 w.Header().Set("Content-Type", "application/json")
851 json.NewEncoder(w).Encode(response)
852 }
853
854 func (s *AdminServer) serveUI(w http.ResponseWriter, r *http.Request) {
855 s.serveAdminUI(w, r)
856 }
857