feat(bridge): separate bridge config from .env, web panel + hot-reload
- BridgeConfigStore: file-backed JSON config (configs/bridge.json) - Auto-creates config file only when user saves via admin panel - Hot-reload: watches config file every 5s, reloads adapters live - Admin API: GET/PUT/DELETE /api/v1/admin/bridge/config/:name - Frontend: full CRUD page with platform type selector + toggle - Removed bridge env vars from .env.example
This commit is contained in:
1 parent
217319eabc
commit
9eaed2883f
5 files changed
+478
-73
No files matched your search
@@ -77,6 +77,13 @@ type PerfMonitor interface {
|
||||
History() []map[string]any
|
||||
}
|
||||
|
||||
// BridgeConfigManager manages platform adapter configurations.
|
||||
type BridgeConfigManager interface {
|
||||
GetAdapters() []map[string]any
|
||||
SaveAdapter(name, adapterType string, enabled bool, settings map[string]string) error
|
||||
DeleteAdapter(name string) error
|
||||
}
|
||||
|
||||
// Dependencies holds all injectable services.
|
||||
type Dependencies struct {
|
||||
AuthMgr AuthManager
|
||||
@@ -87,6 +94,7 @@ type Dependencies struct {
|
||||
LLMLogger LLMCallLogger
|
||||
TraceLog TraceLogger
|
||||
PerfMon PerfMonitor
|
||||
BridgeCfg BridgeConfigManager
|
||||
ModelConfig *ModelConfigStore
|
||||
PluginList []map[string]string
|
||||
}
|
||||
@@ -133,6 +141,8 @@ func (r *Router) setupRoutes() {
|
||||
r.mux.HandleFunc("/api/v1/admin/models/config/", r.authMW(r.adminMW(r.modelConfigItemHandler)))
|
||||
r.mux.HandleFunc("/api/v1/admin/trace", r.authMW(r.adminMW(r.traceHandler)))
|
||||
r.mux.HandleFunc("/api/v1/admin/perf", r.authMW(r.adminMW(r.perfHandler)))
|
||||
r.mux.HandleFunc("/api/v1/admin/bridge/config", r.authMW(r.adminMW(r.bridgeConfigHandler)))
|
||||
r.mux.HandleFunc("/api/v1/admin/bridge/config/", r.authMW(r.adminMW(r.bridgeConfigItemHandler)))
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
@@ -522,6 +532,58 @@ func (r *Router) perfHandler(w http.ResponseWriter, req *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// --- Bridge Config ---
|
||||
|
||||
func (r *Router) bridgeConfigHandler(w http.ResponseWriter, req *http.Request) {
|
||||
if r.deps.BridgeCfg == nil {
|
||||
writeJSON(w, 503, M{"error": "bridge config unavailable"})
|
||||
return
|
||||
}
|
||||
switch req.Method {
|
||||
case "GET":
|
||||
writeJSON(w, 200, M{"adapters": r.deps.BridgeCfg.GetAdapters()})
|
||||
default:
|
||||
writeJSON(w, 405, M{"error": "method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) bridgeConfigItemHandler(w http.ResponseWriter, req *http.Request) {
|
||||
if r.deps.BridgeCfg == nil {
|
||||
writeJSON(w, 503, M{"error": "bridge config unavailable"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(req.URL.Path, "/api/v1/admin/bridge/config/")
|
||||
if name == "" {
|
||||
writeJSON(w, 400, M{"error": "missing adapter name"})
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "PUT", "POST":
|
||||
var body struct {
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Settings map[string]string `json:"settings"`
|
||||
}
|
||||
json.NewDecoder(req.Body).Decode(&body)
|
||||
if err := r.deps.BridgeCfg.SaveAdapter(name, body.Type, body.Enabled, body.Settings); err != nil {
|
||||
writeJSON(w, 500, M{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, M{"ok": true})
|
||||
|
||||
case "DELETE":
|
||||
if err := r.deps.BridgeCfg.DeleteAdapter(name); err != nil {
|
||||
writeJSON(w, 500, M{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 204, nil)
|
||||
|
||||
default:
|
||||
writeJSON(w, 405, M{"error": "method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
type M = map[string]any
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// Package bridge provides configuration management for platform adapters.
|
||||
// Configs are stored as JSON on disk and loaded only when created via the admin panel.
|
||||
// Supports hot-reload: the router watches the config file and reloads adapters on change.
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AdapterConfig holds configuration for a single platform adapter.
|
||||
type AdapterConfig struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "qq-obv11" | "telegram" | "discord" | "webhook"
|
||||
Enabled bool `json:"enabled"`
|
||||
Settings map[string]string `json:"settings"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BridgeConfig is the top-level configuration file structure.
|
||||
type BridgeConfig struct {
|
||||
Version string `json:"version"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Adapters []AdapterConfig `json:"adapters"`
|
||||
}
|
||||
|
||||
// ConfigStore manages bridge configurations with file persistence.
|
||||
type ConfigStore struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
config *BridgeConfig
|
||||
onChange func(*BridgeConfig) // Called when config is loaded or reloaded
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewConfigStore creates a bridge config store. Path is the config file location.
|
||||
// If the file doesn't exist, no config is created — it will be created on first save via admin panel.
|
||||
func NewConfigStore(path string, onChange func(*BridgeConfig)) *ConfigStore {
|
||||
s := &ConfigStore{
|
||||
path: path,
|
||||
config: &BridgeConfig{Version: "1.0", Adapters: []AdapterConfig{}},
|
||||
onChange: onChange,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Try to load existing file
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
s.load()
|
||||
if s.onChange != nil {
|
||||
s.onChange(s.config)
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// StartWatching begins polling the config file for changes (hot-reload).
|
||||
func (s *ConfigStore) StartWatching(interval time.Duration) {
|
||||
var lastMod time.Time
|
||||
if info, err := os.Stat(s.path); err == nil {
|
||||
lastMod = info.ModTime()
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
info, err := os.Stat(s.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().After(lastMod) {
|
||||
lastMod = info.ModTime()
|
||||
s.mu.Lock()
|
||||
if s.load() {
|
||||
log.Printf("[bridge] config hot-reloaded (%d adapters)", len(s.config.Adapters))
|
||||
if s.onChange != nil {
|
||||
s.onChange(s.config)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop stops the file watcher.
|
||||
func (s *ConfigStore) Stop() { close(s.stopCh) }
|
||||
|
||||
// GetAdapters returns all configured adapters.
|
||||
func (s *ConfigStore) GetAdapters() []AdapterConfig {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make([]AdapterConfig, len(s.config.Adapters))
|
||||
copy(result, s.config.Adapters)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAdapter returns a single adapter by name.
|
||||
func (s *ConfigStore) GetAdapter(name string) (*AdapterConfig, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, a := range s.config.Adapters {
|
||||
if a.Name == name {
|
||||
ac := a
|
||||
return &ac, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("adapter %s not found", name)
|
||||
}
|
||||
|
||||
// SaveAdapter creates or updates an adapter config.
|
||||
func (s *ConfigStore) SaveAdapter(cfg AdapterConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
cfg.UpdatedAt = now
|
||||
if cfg.CreatedAt == "" {
|
||||
cfg.CreatedAt = now
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, a := range s.config.Adapters {
|
||||
if a.Name == cfg.Name {
|
||||
// Preserve creation time
|
||||
if a.CreatedAt != "" { cfg.CreatedAt = a.CreatedAt }
|
||||
s.config.Adapters[i] = cfg
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
s.config.Adapters = append(s.config.Adapters, cfg)
|
||||
}
|
||||
|
||||
s.config.UpdatedAt = now
|
||||
return s.persist()
|
||||
}
|
||||
|
||||
// DeleteAdapter removes an adapter config.
|
||||
func (s *ConfigStore) DeleteAdapter(name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
filtered := make([]AdapterConfig, 0)
|
||||
for _, a := range s.config.Adapters {
|
||||
if a.Name != name {
|
||||
filtered = append(filtered, a)
|
||||
}
|
||||
}
|
||||
s.config.Adapters = filtered
|
||||
s.config.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
return s.persist()
|
||||
}
|
||||
|
||||
// Count returns the number of enabled adapters.
|
||||
func (s *ConfigStore) Count() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
n := 0
|
||||
for _, a := range s.config.Adapters {
|
||||
if a.Enabled { n++ }
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// load reads the config file from disk. Returns true if loaded successfully.
|
||||
func (s *ConfigStore) load() bool {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var cfg BridgeConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
log.Printf("[bridge] config parse error: %v", err)
|
||||
return false
|
||||
}
|
||||
if cfg.Adapters == nil {
|
||||
cfg.Adapters = []AdapterConfig{}
|
||||
}
|
||||
s.config = &cfg
|
||||
return true
|
||||
}
|
||||
|
||||
// persist writes the current config to disk.
|
||||
func (s *ConfigStore) persist() error {
|
||||
data, err := json.MarshalIndent(s.config, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
// Ensure parent dir exists
|
||||
os.MkdirAll(filepath.Dir(s.path), 0o755)
|
||||
if err := os.WriteFile(s.path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user