|
|
|
@@ -115,25 +115,25 @@ type ProductionSoundTouchService struct {
|
|
|
|
|
type Config struct {
|
|
|
|
|
// Server settings
|
|
|
|
|
ListenAddr string `env:"LISTEN_ADDR" default:":8080"`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// SoundTouch settings
|
|
|
|
|
DeviceHosts []string `env:"DEVICE_HOSTS" separator:","`
|
|
|
|
|
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"30s"`
|
|
|
|
|
RequestTimeout time.Duration `env:"REQUEST_TIMEOUT" default:"15s"`
|
|
|
|
|
MaxRetries int `env:"MAX_RETRIES" default:"3"`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Connection pool
|
|
|
|
|
MaxConnections int `env:"MAX_CONNECTIONS" default:"10"`
|
|
|
|
|
IdleTimeout time.Duration `env:"IDLE_TIMEOUT" default:"5m"`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Monitoring
|
|
|
|
|
MetricsEnabled bool `env:"METRICS_ENABLED" default:"true"`
|
|
|
|
|
HealthCheckInterval time.Duration `env:"HEALTH_CHECK_INTERVAL" default:"30s"`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Logging
|
|
|
|
|
LogLevel string `env:"LOG_LEVEL" default:"info"`
|
|
|
|
|
LogFormat string `env:"LOG_FORMAT" default:"json"`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Security
|
|
|
|
|
EnableTLS bool `env:"ENABLE_TLS" default:"false"`
|
|
|
|
|
TLSCertFile string `env:"TLS_CERT_FILE"`
|
|
|
|
@@ -145,7 +145,7 @@ func LoadConfig() (*Config, error) {
|
|
|
|
|
if err := env.Parse(cfg); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to parse config: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return cfg, cfg.Validate()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -153,15 +153,15 @@ func (c *Config) Validate() error {
|
|
|
|
|
if len(c.DeviceHosts) == 0 {
|
|
|
|
|
return fmt.Errorf("at least one device host must be specified")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if c.RequestTimeout < time.Second {
|
|
|
|
|
return fmt.Errorf("request timeout must be at least 1 second")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if c.EnableTLS && (c.TLSCertFile == "" || c.TLSKeyFile == "") {
|
|
|
|
|
return fmt.Errorf("TLS cert and key files required when TLS is enabled")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
@@ -191,7 +191,7 @@ pool:
|
|
|
|
|
monitoring:
|
|
|
|
|
metrics_enabled: true
|
|
|
|
|
health_check_interval: "30s"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
logging:
|
|
|
|
|
level: "info"
|
|
|
|
|
format: "json"
|
|
|
|
@@ -203,12 +203,12 @@ func LoadConfigFromFile(path string) (*Config, error) {
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
var cfg Config
|
|
|
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return &cfg, cfg.Validate()
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
@@ -224,14 +224,14 @@ func LoadConfigFromFile(path string) (*Config, error) {
|
|
|
|
|
type SecureNetworkConfig struct {
|
|
|
|
|
// Allowed source IP ranges
|
|
|
|
|
AllowedCIDRs []string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Rate limiting
|
|
|
|
|
RateLimit int
|
|
|
|
|
RateLimitWindow time.Duration
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// TLS configuration
|
|
|
|
|
TLSConfig *tls.Config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Timeouts for security
|
|
|
|
|
ReadTimeout time.Duration
|
|
|
|
|
WriteTimeout time.Duration
|
|
|
|
@@ -240,7 +240,7 @@ type SecureNetworkConfig struct {
|
|
|
|
|
|
|
|
|
|
func NewSecureServer(config SecureNetworkConfig) *http.Server {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Add middleware
|
|
|
|
|
handler := applyMiddleware(mux,
|
|
|
|
|
corsMiddleware(),
|
|
|
|
@@ -249,7 +249,7 @@ func NewSecureServer(config SecureNetworkConfig) *http.Server {
|
|
|
|
|
loggingMiddleware(),
|
|
|
|
|
metricsMiddleware(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return &http.Server{
|
|
|
|
|
Handler: handler,
|
|
|
|
|
TLSConfig: config.TLSConfig,
|
|
|
|
@@ -275,12 +275,12 @@ func (r *DeviceControlRequest) Validate() error {
|
|
|
|
|
if err := validate.Struct(r); err != nil {
|
|
|
|
|
return fmt.Errorf("validation failed: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Additional business logic validation
|
|
|
|
|
if r.Action == "volume" && r.Volume == nil {
|
|
|
|
|
return fmt.Errorf("volume value required for volume action")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
@@ -302,12 +302,12 @@ func loadSecretsFromK8s() (*SecretsConfig, error) {
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tlsKey, err := os.ReadFile("/etc/secrets/tls.key")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return &SecretsConfig{
|
|
|
|
|
TLSCert: string(tlsCert),
|
|
|
|
|
TLSKey: string(tlsKey),
|
|
|
|
@@ -335,21 +335,21 @@ type Logger struct {
|
|
|
|
|
|
|
|
|
|
func NewLogger(level, format, component string) (*Logger, error) {
|
|
|
|
|
logger := logrus.New()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Set level
|
|
|
|
|
logLevel, err := logrus.ParseLevel(level)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
logger.SetLevel(logLevel)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Set format
|
|
|
|
|
if format == "json" {
|
|
|
|
|
logger.SetFormatter(&logrus.JSONFormatter{
|
|
|
|
|
TimestampFormat: time.RFC3339,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return &Logger{
|
|
|
|
|
Logger: logger,
|
|
|
|
|
component: component,
|
|
|
|
@@ -376,15 +376,15 @@ type Metrics struct {
|
|
|
|
|
RequestsTotal prometheus.CounterVec
|
|
|
|
|
RequestDuration prometheus.HistogramVec
|
|
|
|
|
RequestsInFlight prometheus.GaugeVec
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Device metrics
|
|
|
|
|
DevicesConnected prometheus.Gauge
|
|
|
|
|
DeviceHealth prometheus.GaugeVec
|
|
|
|
|
WebSocketConnections prometheus.Gauge
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Error metrics
|
|
|
|
|
ErrorsTotal prometheus.CounterVec
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Business metrics
|
|
|
|
|
VolumeChanges prometheus.CounterVec
|
|
|
|
|
SourceChanges prometheus.CounterVec
|
|
|
|
@@ -400,7 +400,7 @@ func NewMetrics() *Metrics {
|
|
|
|
|
},
|
|
|
|
|
[]string{"method", "endpoint", "status"},
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
RequestDuration: *prometheus.NewHistogramVec(
|
|
|
|
|
prometheus.HistogramOpts{
|
|
|
|
|
Name: "soundtouch_request_duration_seconds",
|
|
|
|
@@ -409,14 +409,14 @@ func NewMetrics() *Metrics {
|
|
|
|
|
},
|
|
|
|
|
[]string{"method", "endpoint"},
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DevicesConnected: prometheus.NewGauge(
|
|
|
|
|
prometheus.GaugeOpts{
|
|
|
|
|
Name: "soundtouch_devices_connected",
|
|
|
|
|
Help: "Number of connected devices",
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DeviceHealth: *prometheus.NewGaugeVec(
|
|
|
|
|
prometheus.GaugeOpts{
|
|
|
|
|
Name: "soundtouch_device_health",
|
|
|
|
@@ -425,7 +425,7 @@ func NewMetrics() *Metrics {
|
|
|
|
|
[]string{"device_id", "device_name"},
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Register metrics
|
|
|
|
|
prometheus.MustRegister(
|
|
|
|
|
m.RequestsTotal,
|
|
|
|
@@ -433,7 +433,7 @@ func NewMetrics() *Metrics {
|
|
|
|
|
m.DevicesConnected,
|
|
|
|
|
m.DeviceHealth,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return m
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -457,7 +457,7 @@ type HealthChecker struct {
|
|
|
|
|
func (hc *HealthChecker) Start(ctx context.Context) {
|
|
|
|
|
ticker := time.NewTicker(hc.interval)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
@@ -470,7 +470,7 @@ func (hc *HealthChecker) Start(ctx context.Context) {
|
|
|
|
|
|
|
|
|
|
func (hc *HealthChecker) checkAllDevices() {
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for deviceID, device := range hc.manager.devices {
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
go func(id string, dev *DeviceInfo) {
|
|
|
|
@@ -478,18 +478,18 @@ func (hc *HealthChecker) checkAllDevices() {
|
|
|
|
|
hc.checkDevice(id, dev)
|
|
|
|
|
}(deviceID, device)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
wg.Wait()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hc *HealthChecker) checkDevice(deviceID string, device *DeviceInfo) {
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), hc.timeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
start := time.Now()
|
|
|
|
|
err := device.Client.Ping()
|
|
|
|
|
duration := time.Since(start)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
device.Status = DeviceStatusUnhealthy
|
|
|
|
|
hc.metrics.DeviceHealth.WithLabelValues(deviceID, device.Name).Set(0)
|
|
|
|
@@ -507,14 +507,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
healthy := 0
|
|
|
|
|
total := 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for _, device := range hc.manager.devices {
|
|
|
|
|
total++
|
|
|
|
|
if device.Status == DeviceStatusHealthy {
|
|
|
|
|
healthy++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
status := map[string]interface{}{
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"devices": map[string]interface{}{
|
|
|
|
@@ -524,14 +524,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
|
|
|
|
|
},
|
|
|
|
|
"timestamp": time.Now().UTC(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if healthy < total {
|
|
|
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
|
|
|
status["status"] = "degraded"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
json.NewEncoder(w).Encode(status)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -560,16 +560,16 @@ func NewConnectionPool(maxIdle, maxActive int, idleTimeout time.Duration) *Conne
|
|
|
|
|
maxActive: maxActive,
|
|
|
|
|
idleTimeout: idleTimeout,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Start cleanup goroutine
|
|
|
|
|
go cp.cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return cp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) {
|
|
|
|
|
key := fmt.Sprintf("%s:%d", host, port)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check if connection exists and is valid
|
|
|
|
|
if val, ok := cp.clients.Load(key); ok {
|
|
|
|
|
conn := val.(*pooledConnection)
|
|
|
|
@@ -580,35 +580,35 @@ func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) {
|
|
|
|
|
// Connection expired, remove it
|
|
|
|
|
cp.clients.Delete(key)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check active connection limit
|
|
|
|
|
if atomic.LoadInt64(&cp.activeCount) >= int64(cp.maxActive) {
|
|
|
|
|
return nil, fmt.Errorf("connection pool exhausted")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Create new connection
|
|
|
|
|
config := client.ClientConfig{
|
|
|
|
|
Host: host,
|
|
|
|
|
Port: port,
|
|
|
|
|
Timeout: 15 * time.Second,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
newClient := client.NewClient(config)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Test connection
|
|
|
|
|
if err := newClient.Ping(); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to connect to %s:%d: %w", host, port, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
conn := &pooledConnection{
|
|
|
|
|
client: newClient,
|
|
|
|
|
lastUsed: time.Now(),
|
|
|
|
|
created: time.Now(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
cp.clients.Store(key, conn)
|
|
|
|
|
atomic.AddInt64(&cp.activeCount, 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return newClient, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -621,7 +621,7 @@ type pooledConnection struct {
|
|
|
|
|
func (cp *ConnectionPool) cleanup() {
|
|
|
|
|
ticker := time.NewTicker(cp.idleTimeout / 2)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for range ticker.C {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
cp.clients.Range(func(key, val interface{}) bool {
|
|
|
|
@@ -649,10 +649,10 @@ func NewCacheManager() *CacheManager {
|
|
|
|
|
return &CacheManager{
|
|
|
|
|
// Device info rarely changes, cache for 1 hour
|
|
|
|
|
deviceInfoCache: cache.New(1*time.Hour, 2*time.Hour),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Capabilities never change, cache for 24 hours
|
|
|
|
|
capabilitiesCache: cache.New(24*time.Hour, 48*time.Hour),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Volume changes frequently, cache for 5 seconds
|
|
|
|
|
volumeCache: cache.New(5*time.Second, 10*time.Second),
|
|
|
|
|
}
|
|
|
|
@@ -662,12 +662,12 @@ func (cm *CacheManager) GetDeviceInfo(deviceID string, fetcher func() (*models.D
|
|
|
|
|
if cached, found := cm.deviceInfoCache.Get(deviceID); found {
|
|
|
|
|
return cached.(*models.DeviceInfo), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
info, err := fetcher()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
cm.deviceInfoCache.Set(deviceID, info, cache.DefaultExpiration)
|
|
|
|
|
return info, nil
|
|
|
|
|
}
|
|
|
|
@@ -702,7 +702,7 @@ func NewResilientSoundTouchService(client *client.Client) *ResilientSoundTouchSe
|
|
|
|
|
log.Printf("Circuit breaker '%s' changed from '%s' to '%s'", name, from, to)
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return &ResilientSoundTouchService{
|
|
|
|
|
client: client,
|
|
|
|
|
cb: gobreaker.NewCircuitBreaker(settings),
|
|
|
|
@@ -713,12 +713,12 @@ func (r *ResilientSoundTouchService) SetVolume(deviceID string, volume int) erro
|
|
|
|
|
result, err := r.cb.Execute(func() (interface{}, error) {
|
|
|
|
|
return nil, r.client.SetVolume(volume)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
r.metrics.ErrorsTotal.WithLabelValues("circuit_breaker", "volume").Inc()
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return result.(error)
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
@@ -730,16 +730,16 @@ func (app *Application) Run(ctx context.Context) error {
|
|
|
|
|
// Setup signal handling
|
|
|
|
|
sigChan := make(chan os.Signal, 1)
|
|
|
|
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Start services
|
|
|
|
|
g, ctx := errgroup.WithContext(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// HTTP server
|
|
|
|
|
server := &http.Server{
|
|
|
|
|
Addr: app.config.ListenAddr,
|
|
|
|
|
Handler: app.handler,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
g.Go(func() error {
|
|
|
|
|
app.logger.Info("Starting HTTP server", "addr", app.config.ListenAddr)
|
|
|
|
|
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
|
|
|
@@ -747,38 +747,38 @@ func (app *Application) Run(ctx context.Context) error {
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Health checker
|
|
|
|
|
g.Go(func() error {
|
|
|
|
|
return app.healthChecker.Start(ctx)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// WebSocket manager
|
|
|
|
|
g.Go(func() error {
|
|
|
|
|
return app.wsManager.Start(ctx)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Wait for shutdown signal
|
|
|
|
|
go func() {
|
|
|
|
|
<-sigChan
|
|
|
|
|
app.logger.Info("Shutdown signal received")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Graceful shutdown with timeout
|
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Shutdown HTTP server
|
|
|
|
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
|
|
|
|
app.logger.Error("HTTP server shutdown error", "error", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Close WebSocket connections
|
|
|
|
|
app.wsManager.Shutdown(shutdownCtx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Close connection pool
|
|
|
|
|
app.connectionPool.Close()
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return g.Wait()
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
@@ -791,7 +791,7 @@ func (app *Application) Run(ctx context.Context) error {
|
|
|
|
|
|
|
|
|
|
```dockerfile
|
|
|
|
|
# Dockerfile
|
|
|
|
|
FROM golang:1.25-alpine AS builder
|
|
|
|
|
FROM golang:1.27.0-alpine AS builder
|
|
|
|
|
|
|
|
|
|
WORKDIR /app
|
|
|
|
|
COPY go.mod go.sum ./
|
|
|
|
@@ -830,7 +830,7 @@ services:
|
|
|
|
|
networks:
|
|
|
|
|
- soundtouch-net
|
|
|
|
|
restart: unless-stopped
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
prometheus:
|
|
|
|
|
image: prom/prometheus:latest
|
|
|
|
|
ports:
|
|
|
|
@@ -839,7 +839,7 @@ services:
|
|
|
|
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
|
|
|
|
networks:
|
|
|
|
|
- soundtouch-net
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
grafana:
|
|
|
|
|
image: grafana/grafana:latest
|
|
|
|
|
ports:
|
|
|
|
@@ -1011,7 +1011,7 @@ groups:
|
|
|
|
|
annotations:
|
|
|
|
|
summary: "SoundTouch device {{ $labels.device_name }} is unhealthy"
|
|
|
|
|
description: "Device {{ $labels.device_id }} has been unhealthy for more than 2 minutes"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- alert: HighErrorRate
|
|
|
|
|
expr: rate(soundtouch_errors_total[5m]) > 0.1
|
|
|
|
|
for: 5m
|
|
|
|
@@ -1020,7 +1020,7 @@ groups:
|
|
|
|
|
annotations:
|
|
|
|
|
summary: "High error rate detected"
|
|
|
|
|
description: "Error rate is {{ $value }} errors/second over the last 5 minutes"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- alert: ServiceDown
|
|
|
|
|
expr: up{job="soundtouch"} == 0
|
|
|
|
|
for: 1m
|
|
|
|
@@ -1040,33 +1040,33 @@ func (m *Manager) BackupConfigurations() error {
|
|
|
|
|
Timestamp: time.Now(),
|
|
|
|
|
Devices: make(map[string]DeviceConfig),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for deviceID, device := range m.devices {
|
|
|
|
|
config := DeviceConfig{}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Backup presets
|
|
|
|
|
if presets, err := device.Client.GetPresets(); err == nil {
|
|
|
|
|
config.Presets = presets
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Backup settings
|
|
|
|
|
if volume, err := device.Client.GetVolume(); err == nil {
|
|
|
|
|
config.Volume = volume.TargetVolume
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if bass, err := device.Client.GetBass(); err == nil {
|
|
|
|
|
config.Bass = bass.TargetBass
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backup.Devices[deviceID] = config
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Save to file
|
|
|
|
|
data, err := json.MarshalIndent(backup, "", " ")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
filename := fmt.Sprintf("backup_%s.json", time.Now().Format("2006-01-02_15-04-05"))
|
|
|
|
|
return os.WriteFile(filepath.Join(m.config.BackupDir, filename), data, 0644)
|
|
|
|
|
}
|
|
|
|
@@ -1083,7 +1083,7 @@ func init() {
|
|
|
|
|
runtime.GOMAXPROCS(int(limit))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Set GC target percentage
|
|
|
|
|
if os.Getenv("GOGC") == "" {
|
|
|
|
|
debug.SetGCPerc
|
|
|
|
|