ソースを参照

Add new 'Logs' page to the UI to view stdout logs

Kailash Nadh 4 年 前
コミット
8dbe30cd26

+ 1 - 0
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)

+ 9 - 3
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)

+ 6 - 0
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

+ 13 - 3
frontend/src/App.vue

@@ -78,9 +78,19 @@
                     icon="file-image-outline" label="Templates"></b-menu-item>
                 </b-menu-item><!-- campaigns -->
 
-                <b-menu-item :to="{name: 'settings'}" tag="router-link"
-                  :active="activeItem.settings"
-                  icon="cog-outline" label="Settings"></b-menu-item>
+                <b-menu-item :expanded="activeGroup.settings"
+                  :active="activeGroup.settings"
+                  v-on:update:active="(state) => toggleGroup('settings', state)"
+                  icon="cog-outline" label="Settings">
+
+                  <b-menu-item :to="{name: 'settings'}" tag="router-link"
+                    :active="activeItem.settings"
+                    icon="cog-outline" label="Settings"></b-menu-item>
+
+                  <b-menu-item :to="{name: 'logs'}" tag="router-link"
+                    :active="activeItem.logs"
+                    icon="newspaper-variant-outline" label="Logs"></b-menu-item>
+                </b-menu-item><!-- settings -->
               </b-menu-list>
             </b-menu>
           </div>

+ 3 - 0
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 });

+ 25 - 1
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 {

+ 1 - 0
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.

+ 6 - 0
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({

+ 1 - 0
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: {

+ 1 - 1
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;

+ 56 - 0
frontend/src/views/Logs.vue

@@ -0,0 +1,56 @@
+<template>
+  <section class="logs content relative">
+    <h1 class="title is-4">Logs</h1>
+    <hr />
+    <b-loading :active="loading.logs" :is-full-page="false" />
+    <pre class="lines" ref="lines">
+<template v-for="(l, i) in lines"><span v-html="formatLine(l)" :key="i" class="line"></span>
+</template>
+    </pre>
+  </section>
+</template>
+
+<script>
+import Vue from 'vue';
+import { mapState } from 'vuex';
+
+const reFormatLine = new RegExp(/^(.*) (.+?)\.go:[0-9]+:\s/g);
+
+export default Vue.extend({
+  data() {
+    return {
+      lines: '',
+      pollId: null,
+    };
+  },
+
+  methods: {
+    formatLine: (l) => l.replace(reFormatLine, '<span class="stamp">$1</span> '),
+
+    getLogs() {
+      this.$api.getLogs().then((data) => {
+        this.lines = data;
+
+        this.$nextTick(() => {
+          this.$refs.lines.scrollTop = this.$refs.lines.scrollHeight;
+        });
+      });
+    },
+  },
+
+  computed: {
+    ...mapState(['logs', 'loading']),
+  },
+
+  mounted() {
+    this.getLogs();
+
+    // Update the logs every 10 seconds.
+    this.pollId = setInterval(() => this.getLogs(), 10000);
+  },
+
+  destroyed() {
+    clearInterval(this.pollId);
+  },
+});
+</script>

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