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