diff --git a/cmd/handlers.go b/cmd/handlers.go index 68604bc3..2efdc795 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -107,6 +107,7 @@ func initHTTPHandlers(e *echo.Echo, a *App) { g.GET("/api/settings", pm(a.GetSettings, "settings:get")) g.PUT("/api/settings", pm(a.UpdateSettings, "settings:manage")) + g.PUT("/api/settings/:key", pm(a.UpdateSettingsByKey, "settings:manage")) g.POST("/api/settings/smtp/test", pm(a.TestSMTPSettings, "settings:manage")) g.POST("/api/admin/reload", pm(a.ReloadApp, "settings:manage")) g.GET("/api/logs", pm(a.GetLogs, "settings:get")) diff --git a/cmd/init.go b/cmd/init.go index 24a1af37..613164ba 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -8,7 +8,9 @@ import ( "errors" "fmt" "html/template" + "io" "log" + "log/slog" "maps" "net/http" "os" @@ -941,27 +943,49 @@ func initCaptcha() *captcha.Captcha { return captcha.New(opt) } -// initCron initializes the cron job for refreshing slow query cache. -func initCron(co *core.Core) { - intval := ko.String("app.cache_slow_queries_interval") - if intval == "" { - lo.Println("error: invalid cron interval string") - return +// initCron initializes cron jobs for slow query cache refresh and database vacuum. +func initCron(co *core.Core, db *sqlx.DB) { + c := cron.New(cron.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))) + + // Slow query cache cron job. + if ko.Bool("app.cache_slow_queries") { + intval := ko.String("app.cache_slow_queries_interval") + if intval == "" { + lo.Println("error: invalid cron interval string for slow query cache") + } else { + _, err := c.Add(intval, func() { + lo.Println("refreshing slow query cache") + _ = co.RefreshMatViews(true) + lo.Println("done refreshing slow query cache") + }) + if err != nil { + lo.Printf("error initializing slow cache query cron: %v", err) + } else { + lo.Printf("IMPORTANT: database slow query caching is enabled. Aggregate numbers and stats will not be realtime. Next refresh at: %v", c.Entries()[len(c.Entries())-1].Next) + } + } } - c := cron.New() - _, err := c.Add(intval, func() { - lo.Println("refreshing slow query cache") - _ = co.RefreshMatViews(true) - lo.Println("done refreshing slow query cache") - }) - if err != nil { - lo.Printf("error initializing slow cache query cron: %v", err) - return + // Database vacuum cron job. + if ko.Bool("maintenance.db.vacuum") { + intval := ko.String("maintenance.db.vacuum_cron_interval") + if intval == "" { + lo.Println("error: invalid cron interval string for database vacuum") + } else { + _, err := c.Add(intval, func() { + RunDBVacuum(db, lo) + }) + if err != nil { + lo.Printf("error initializing database vacuum cron: %v", err) + } else { + lo.Printf("database VACUUM cron enabled at interval: %s", intval) + } + } } - c.Start() - lo.Printf("IMPORTANT: database slow query caching is enabled. Aggregate numbers and stats will not be realtime. Next refresh at: %v", c.Entries()[0].Next) + if len(c.Entries()) > 0 { + c.Start() + } } // awaitReload waits for a SIGHUP signal to reload the app. Every setting change on the UI causes a reload. diff --git a/cmd/main.go b/cmd/main.go index c10f658d..082303a2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -232,9 +232,7 @@ func main() { } // Start cronjobs. - if ko.Bool("app.cache_slow_queries") { - initCron(core) - } + initCron(core, db) // Start the campaign manager workers. The campaign batches (fetch from DB, push out // messages) get processed at the specified interval. diff --git a/cmd/maintenance.go b/cmd/maintenance.go index 5bdac8c0..2a8d8395 100644 --- a/cmd/maintenance.go +++ b/cmd/maintenance.go @@ -1,9 +1,11 @@ package main import ( + "log" "net/http" "time" + "github.com/jmoiron/sqlx" "github.com/labstack/echo/v4" ) @@ -81,3 +83,14 @@ func (a *App) GCCampaignAnalytics(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } + +// RunDBVacuum runs a full VACUUM on the PostgreSQL database. +// VACUUM reclaims storage occupied by dead tuples and updates planner statistics. +func RunDBVacuum(db *sqlx.DB, lo *log.Logger) { + lo.Println("running database VACUUM ANALYZE") + if _, err := db.Exec("VACUUM ANALYZE"); err != nil { + lo.Printf("error running VACUUM ANALYZE: %v", err) + return + } + lo.Println("finished database VACUUM ANALYZE") +} diff --git a/cmd/settings.go b/cmd/settings.go index ccec8bc9..12ecf2f7 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/json" "io" "net/http" "net/url" @@ -15,7 +16,7 @@ import ( "github.com/gdgvda/cron" "github.com/gofrs/uuid/v5" "github.com/jmoiron/sqlx/types" - "github.com/knadh/koanf/parsers/json" + koanfjson "github.com/knadh/koanf/parsers/json" "github.com/knadh/koanf/providers/rawbytes" "github.com/knadh/koanf/v2" "github.com/knadh/listmonk/internal/auth" @@ -290,6 +291,33 @@ func (a *App) UpdateSettings(c echo.Context) error { return err } + return a.handleSettingsRestart(c) +} + +// UpdateSettingsByKey updates a single setting key-value in the DB. +func (a *App) UpdateSettingsByKey(c echo.Context) error { + key := c.Param("key") + if key == "" { + return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData")) + } + + // Read the raw JSON body as the value. + var b json.RawMessage + if err := c.Bind(&b); err != nil { + return err + } + + // Update the value in the DB. + if err := a.core.UpdateSettingsByKey(key, b); err != nil { + return err + } + + return a.handleSettingsRestart(c) +} + +// handleSettingsRestart checks for running campaigns and either triggers an +// immediate app restart or marks the app as needing a restart. +func (a *App) handleSettingsRestart(c echo.Context) error { // If there are any active campaigns, don't do an auto reload and // warn the user on the frontend. if a.manager.HasRunningCampaigns() { @@ -302,7 +330,7 @@ func (a *App) UpdateSettings(c echo.Context) error { }{true}}) } - // No running campaigns. Reload the a. + // No running campaigns. Reload the app. go func() { <-time.After(time.Millisecond * 500) a.chReload <- syscall.SIGHUP @@ -327,7 +355,7 @@ func (a *App) TestSMTPSettings(c echo.Context) error { // Load the JSON into koanf to parse SMTP settings properly including timestrings. ko := koanf.New(".") - if err := ko.Load(rawbytes.Provider(reqBody), json.Parser()); err != nil { + if err := ko.Load(rawbytes.Provider(reqBody), koanfjson.Parser()); err != nil { a.log.Printf("error unmarshalling SMTP test request: %v", err) return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.internalError")) } diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 842e4e78..cac1af8f 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -431,6 +431,12 @@ export const updateSettings = async (data) => http.put( { loading: models.settings }, ); +export const updateSettingsByKey = async (key, data) => http.put( + `/api/settings/${key}`, + data, + { loading: models.settings }, +); + export const testSMTP = async (data) => http.post( '/api/settings/smtp/test', data, diff --git a/frontend/src/main.js b/frontend/src/main.js index 0b914c5c..e36c137f 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -101,6 +101,31 @@ const v = new Vue({ loadConfig() { initConfig(); }, + + // awaitRestart handles app restart polling after settings changes. + // Shows a toast and polls until the backend is back up. + // Returns a promise that resolves with { needsRestart: boolean }. + awaitRestart(response) { + return new Promise((resolve) => { + // If there are running campaigns, app won't auto restart. + if (response && typeof response === 'object' && response.needsRestart) { + this.loadConfig(); + resolve({ needsRestart: true }); + return; + } + + Vue.prototype.$utils.toast(i18n.t('settings.messengers.messageSaved')); + + // Poll until backend is back up. + const pollId = setInterval(() => { + api.getHealth().then(() => { + clearInterval(pollId); + this.loadConfig(); + resolve({ needsRestart: false }); + }); + }, 1000); + }); + }, }, mounted() { diff --git a/frontend/src/views/Maintenance.vue b/frontend/src/views/Maintenance.vue index 0363f126..fdeed2a2 100644 --- a/frontend/src/views/Maintenance.vue +++ b/frontend/src/views/Maintenance.vue @@ -107,6 +107,39 @@ + +
+ +