| package dashboard |
|
|
| import ( |
| "encoding/json" |
| "net/http" |
| "sync" |
| "time" |
|
|
| "github.com/pinchtab/pinchtab/internal/bridge" |
| "github.com/pinchtab/pinchtab/internal/config" |
| "github.com/pinchtab/pinchtab/internal/web" |
| ) |
|
|
| type profileLister interface { |
| List() ([]bridge.ProfileInfo, error) |
| } |
|
|
| type runtimeConfigApplier interface { |
| ApplyRuntimeConfig(*config.RuntimeConfig) |
| } |
|
|
| type ConfigAPI struct { |
| runtime *config.RuntimeConfig |
| instances InstanceLister |
| profiles profileLister |
| applier runtimeConfigApplier |
| version string |
| startedAt time.Time |
| boot config.FileConfig |
| mu sync.RWMutex |
| } |
|
|
| type configEnvelope struct { |
| Config config.FileConfig `json:"config"` |
| ConfigPath string `json:"configPath"` |
| RestartRequired bool `json:"restartRequired"` |
| RestartReasons []string `json:"restartReasons,omitempty"` |
| } |
|
|
| type tokenEnvelope struct { |
| Token string `json:"token"` |
| } |
|
|
| type healthInstanceInfo struct { |
| ID string `json:"id"` |
| Status string `json:"status"` |
| } |
|
|
| type healthEnvelope struct { |
| Status string `json:"status"` |
| Mode string `json:"mode"` |
| Version string `json:"version"` |
| Uptime int64 `json:"uptime"` |
| Profiles int `json:"profiles"` |
| Instances int `json:"instances"` |
| DefaultInstance *healthInstanceInfo `json:"defaultInstance,omitempty"` |
| Agents int `json:"agents"` |
| RestartRequired bool `json:"restartRequired"` |
| RestartReasons []string `json:"restartReasons,omitempty"` |
| } |
|
|
| func NewConfigAPI( |
| runtime *config.RuntimeConfig, |
| instances InstanceLister, |
| profiles profileLister, |
| applier runtimeConfigApplier, |
| version string, |
| startedAt time.Time, |
| ) *ConfigAPI { |
| boot := config.DefaultFileConfig() |
| if runtime != nil { |
| boot = config.FileConfigFromRuntime(runtime) |
| } |
| return &ConfigAPI{ |
| runtime: runtime, |
| instances: instances, |
| profiles: profiles, |
| applier: applier, |
| version: version, |
| startedAt: startedAt, |
| boot: boot, |
| } |
| } |
|
|
| func (c *ConfigAPI) RegisterHandlers(mux *http.ServeMux) { |
| mux.HandleFunc("GET /api/config", c.HandleGetConfig) |
| mux.HandleFunc("PUT /api/config", c.HandlePutConfig) |
| mux.HandleFunc("POST /api/config/generate-token", c.HandleGenerateToken) |
| } |
|
|
| func (c *ConfigAPI) HandleHealth(w http.ResponseWriter, r *http.Request) { |
| info, err := c.healthInfo() |
| if err != nil { |
| web.Error(w, 500, err) |
| return |
| } |
| web.JSON(w, 200, info) |
| } |
|
|
| func (c *ConfigAPI) HandleGetConfig(w http.ResponseWriter, r *http.Request) { |
| cfg, path, restartReasons, err := c.currentConfig() |
| if err != nil { |
| web.Error(w, 500, err) |
| return |
| } |
| web.JSON(w, 200, configEnvelope{ |
| Config: cfg, |
| ConfigPath: path, |
| RestartRequired: len(restartReasons) > 0, |
| RestartReasons: restartReasons, |
| }) |
| } |
|
|
| func (c *ConfigAPI) HandlePutConfig(w http.ResponseWriter, r *http.Request) { |
| normalized := config.DefaultFileConfig() |
| if err := json.NewDecoder(r.Body).Decode(&normalized); err != nil { |
| web.ErrorCode(w, 400, "bad_config_json", "invalid config payload", false, nil) |
| return |
| } |
|
|
| if errs := config.ValidateFileConfig(&normalized); len(errs) > 0 { |
| messages := make([]string, 0, len(errs)) |
| for _, validationErr := range errs { |
| messages = append(messages, validationErr.Error()) |
| } |
| web.ErrorCode(w, 400, "invalid_config", "config validation failed", false, map[string]any{ |
| "errors": messages, |
| }) |
| return |
| } |
|
|
| c.mu.Lock() |
| defer c.mu.Unlock() |
|
|
| _, path, err := config.LoadFileConfig() |
| if err != nil { |
| web.Error(w, 500, err) |
| return |
| } |
| if err := config.SaveFileConfig(&normalized, path); err != nil { |
| web.Error(w, 500, err) |
| return |
| } |
|
|
| config.ApplyFileConfigToRuntime(c.runtime, &normalized) |
| if c.applier != nil { |
| c.applier.ApplyRuntimeConfig(c.runtime) |
| } |
|
|
| restartReasons := c.restartReasonsFor(normalized) |
| web.JSON(w, 200, configEnvelope{ |
| Config: normalized, |
| ConfigPath: path, |
| RestartRequired: len(restartReasons) > 0, |
| RestartReasons: restartReasons, |
| }) |
| } |
|
|
| func (c *ConfigAPI) HandleGenerateToken(w http.ResponseWriter, r *http.Request) { |
| token, err := config.GenerateAuthToken() |
| if err != nil { |
| web.Error(w, 500, err) |
| return |
| } |
| web.JSON(w, 200, tokenEnvelope{Token: token}) |
| } |
|
|
| func (c *ConfigAPI) healthInfo() (healthEnvelope, error) { |
| _, _, restartReasons, err := c.currentConfig() |
| if err != nil { |
| return healthEnvelope{}, err |
| } |
|
|
| profileCount := 0 |
| if c.profiles != nil { |
| profiles, err := c.profiles.List() |
| if err == nil { |
| profileCount = len(profiles) |
| } |
| } |
|
|
| instanceCount := 0 |
| var defaultInst *healthInstanceInfo |
| if c.instances != nil { |
| instances := c.instances.List() |
| instanceCount = len(instances) |
| if len(instances) > 0 { |
| defaultInst = &healthInstanceInfo{ |
| ID: instances[0].ID, |
| Status: instances[0].Status, |
| } |
| } |
| } |
| return healthEnvelope{ |
| Status: "ok", |
| Mode: "dashboard", |
| Version: c.version, |
| Uptime: int64(time.Since(c.startedAt).Milliseconds()), |
| Profiles: profileCount, |
| Instances: instanceCount, |
| DefaultInstance: defaultInst, |
| Agents: 0, |
| RestartRequired: len(restartReasons) > 0, |
| RestartReasons: restartReasons, |
| }, nil |
| } |
|
|
| func (c *ConfigAPI) currentConfig() (config.FileConfig, string, []string, error) { |
| c.mu.RLock() |
| defer c.mu.RUnlock() |
|
|
| fc, path, err := config.LoadFileConfig() |
| if err != nil { |
| return config.FileConfig{}, "", nil, err |
| } |
| restartReasons := c.restartReasonsFor(*fc) |
| return *fc, path, restartReasons, nil |
| } |
|
|
| func (c *ConfigAPI) restartReasonsFor(next config.FileConfig) []string { |
| reasons := make([]string, 0, 4) |
|
|
| if c.boot.Server.Port != next.Server.Port || c.boot.Server.Bind != next.Server.Bind { |
| reasons = append(reasons, "Server address") |
| } |
| if c.boot.Profiles.BaseDir != next.Profiles.BaseDir { |
| reasons = append(reasons, "Profiles directory") |
| } |
| if c.boot.MultiInstance.Strategy != next.MultiInstance.Strategy { |
| reasons = append(reasons, "Routing strategy") |
| } |
| if !sameIntPtr(c.boot.MultiInstance.Restart.MaxRestarts, next.MultiInstance.Restart.MaxRestarts) || |
| !sameIntPtr(c.boot.MultiInstance.Restart.InitBackoffSec, next.MultiInstance.Restart.InitBackoffSec) || |
| !sameIntPtr(c.boot.MultiInstance.Restart.MaxBackoffSec, next.MultiInstance.Restart.MaxBackoffSec) || |
| !sameIntPtr(c.boot.MultiInstance.Restart.StableAfterSec, next.MultiInstance.Restart.StableAfterSec) { |
| reasons = append(reasons, "Restart policy") |
| } |
|
|
| return reasons |
| } |
|
|
| func sameIntPtr(a, b *int) bool { |
| if a == nil || b == nil { |
| return a == b |
| } |
| return *a == *b |
| } |
|
|