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
+}