diff --git a/cmd/handlers.go b/cmd/handlers.go index ed641e9..c05543c 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -43,6 +43,7 @@ func registerHTTPHandlers(e *echo.Echo) { g.GET("/api/settings", handleGetSettings) g.PUT("/api/settings", handleUpdateSettings) g.POST("/api/admin/reload", handleReloadApp) + g.GET("/api/logs", handleGetLogs) g.GET("/api/subscribers/:id", handleGetSubscriber) g.GET("/api/subscribers/:id/export", handleExportSubscriberData) diff --git a/cmd/main.go b/cmd/main.go index 048d33a..3bdf9b1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "html/template" + "io" "log" "os" "os/signal" @@ -15,6 +16,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/knadh/koanf" "github.com/knadh/koanf/providers/env" + "github.com/knadh/listmonk/internal/buflog" "github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/internal/messenger" @@ -39,6 +41,7 @@ type App struct { media media.Store notifTpls *template.Template log *log.Logger + bufLog *buflog.BufLog // Channel for passing reload signals. sigChan chan os.Signal @@ -53,9 +56,12 @@ type App struct { } var ( - lo = log.New(os.Stdout, "", log.Ldate|log.Ltime|log.Lshortfile) - ko = koanf.New(".") + // Buffered log writer for storing N lines of log entries for the UI. + bufLog = buflog.New(5000) + lo = log.New(io.MultiWriter(os.Stdout, bufLog), "", + log.Ldate|log.Ltime|log.Lshortfile) + ko = koanf.New(".") fs stuffbin.FileSystem db *sqlx.DB queries *Queries @@ -119,7 +125,6 @@ func init() { // Load settings from DB. initSettings(queries) - } func main() { @@ -132,6 +137,7 @@ func main() { media: initMediaStore(), messengers: make(map[string]messenger.Messenger), log: lo, + bufLog: bufLog, } _, app.queries = initQueries(queryFilePath, db, fs, true) app.manager = initCampaignManager(app.queries, app.constants, app) diff --git a/cmd/settings.go b/cmd/settings.go index 5d9e4c6..7ab26c4 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -217,6 +217,12 @@ func handleUpdateSettings(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } +// handleGetLogs returns the log entries stored in the log buffer. +func handleGetLogs(c echo.Context) error { + app := c.Get("app").(*App) + return c.JSON(http.StatusOK, okResp{app.bufLog.Lines()}) +} + func getSettings(app *App) (settings, error) { var ( b types.JSONText diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 18a08d2..7b5c9aa 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -78,9 +78,19 @@ icon="file-image-outline" label="Templates"> - + + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 6c82c33..95fefc8 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -200,3 +200,6 @@ export const getSettings = async () => http.get('/api/settings', export const updateSettings = async (data) => http.put('/api/settings', data, { loading: models.settings }); + +export const getLogs = async () => http.get('/api/logs', + { loading: models.logs }); diff --git a/frontend/src/assets/style.scss b/frontend/src/assets/style.scss index 1750c9e..15e4b74 100644 --- a/frontend/src/assets/style.scss +++ b/frontend/src/assets/style.scss @@ -11,6 +11,7 @@ $turquoise: $green; $red: #ff5722; $link: $primary; $input-placeholder-color: $grey-light; +$grey-lightest: #eaeaea; $colors: map-merge($colors, ( "turquoise": ($green, $green-invert), @@ -41,6 +42,11 @@ code { color: $grey; } +pre { + background: none; + border: 1px solid $grey-lightest; +} + ul.no { list-style-type: none; padding: 0; @@ -226,7 +232,7 @@ section { } thead th, tbody td { padding: 15px 10px; - border-color: #eaeaea; + border-color: $grey-lightest; } .actions a { margin: 0 10px; @@ -600,6 +606,24 @@ section.campaign { } } +/* Logs */ +.logs { + .lines { + height: 70vh; + overflow-y: scroll; + + .stamp { + color: $primary; + display: inline-block; + min-width: 160px; + } + + .line:hover { + background: $white-bis; + } + } +} + /* C3 charting lib */ .c3 { .c3-chart-lines .c3-line { diff --git a/frontend/src/constants.js b/frontend/src/constants.js index fd2cbea..ffed0bb 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -10,6 +10,7 @@ export const models = Object.freeze({ templates: 'templates', media: 'media', settings: 'settings', + logs: 'logs', }); // Ad-hoc URIs that are used outside of vuex requests. diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ecc24e8..e19437a 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -77,6 +77,12 @@ const routes = [ meta: { title: 'Settings', group: 'settings' }, component: () => import(/* webpackChunkName: "main" */ '../views/Settings.vue'), }, + { + path: '/settings/logs', + name: 'logs', + meta: { title: 'Logs', group: 'settings' }, + component: () => import(/* webpackChunkName: "main" */ '../views/Logs.vue'), + }, ]; const router = new VueRouter({ diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 239eb0d..dabfcc3 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -43,6 +43,7 @@ export default new Vuex.Store({ [models.templates]: (state) => state[models.templates], [models.settings]: (state) => state[models.settings], [models.serverConfig]: (state) => state[models.serverConfig], + [models.logs]: (state) => state[models.logs], }, modules: { diff --git a/frontend/src/views/Import.vue b/frontend/src/views/Import.vue index 5427650..82aff78 100644 --- a/frontend/src/views/Import.vue +++ b/frontend/src/views/Import.vue @@ -245,7 +245,7 @@ export default Vue.extend({ this.logs = data; Vue.nextTick(() => { - // vue.$refs doesn't work as the logs textarea is rendered dynamiaclly. + // vue.$refs doesn't work as the logs textarea is rendered dynamically. const ref = document.getElementById('import-log'); if (ref) { ref.scrollTop = ref.scrollHeight; diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue new file mode 100644 index 0000000..343d4d9 --- /dev/null +++ b/frontend/src/views/Logs.vue @@ -0,0 +1,56 @@ + + + diff --git a/internal/buflog/buflog.go b/internal/buflog/buflog.go new file mode 100644 index 0000000..d9d64bf --- /dev/null +++ b/internal/buflog/buflog.go @@ -0,0 +1,50 @@ +package buflog + +import ( + "bytes" + "strings" + "sync" +) + +// BufLog implements a simple log buffer that can be supplied to a std +// log instance. It stores logs up to N lines. +type BufLog struct { + maxLines int + buf *bytes.Buffer + lines []string + + sync.RWMutex +} + +// New returns a new log buffer that stores up to maxLines lines. +func New(maxLines int) *BufLog { + return &BufLog{ + maxLines: maxLines, + buf: &bytes.Buffer{}, + lines: make([]string, 0, maxLines), + } +} + +// Write writes a log item to the buffer maintaining maxLines capacity +// using LIFO. +func (bu *BufLog) Write(b []byte) (n int, err error) { + bu.Lock() + if len(bu.lines) >= bu.maxLines { + bu.lines[0] = "" + bu.lines = bu.lines[1:len(bu.lines)] + } + + bu.lines = append(bu.lines, strings.TrimSpace(string(b))) + bu.Unlock() + return len(b), nil +} + +// Lines returns the log lines. +func (bu *BufLog) Lines() []string { + bu.RLock() + defer bu.RUnlock() + + out := make([]string, len(bu.lines)) + copy(out[:], bu.lines[:]) + return out +}