WitNote / internal /dashboard /config_api.go
AUXteam's picture
Upload folder using huggingface_hub
6a7089a verified
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
}