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

This commit is contained in:
Kailash Nadh 2020-10-10 23:54:03 +05:30
parent f81d75a787
commit 8dbe30cd26
12 changed files with 172 additions and 8 deletions

View file

@ -43,6 +43,7 @@ func registerHTTPHandlers(e *echo.Echo) {
g.GET("/api/settings", handleGetSettings) g.GET("/api/settings", handleGetSettings)
g.PUT("/api/settings", handleUpdateSettings) g.PUT("/api/settings", handleUpdateSettings)
g.POST("/api/admin/reload", handleReloadApp) g.POST("/api/admin/reload", handleReloadApp)
g.GET("/api/logs", handleGetLogs)
g.GET("/api/subscribers/:id", handleGetSubscriber) g.GET("/api/subscribers/:id", handleGetSubscriber)
g.GET("/api/subscribers/:id/export", handleExportSubscriberData) g.GET("/api/subscribers/:id/export", handleExportSubscriberData)

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"html/template" "html/template"
"io"
"log" "log"
"os" "os"
"os/signal" "os/signal"
@ -15,6 +16,7 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/knadh/koanf" "github.com/knadh/koanf"
"github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/env"
"github.com/knadh/listmonk/internal/buflog"
"github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/internal/media"
"github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/messenger"
@ -39,6 +41,7 @@ type App struct {
media media.Store media media.Store
notifTpls *template.Template notifTpls *template.Template
log *log.Logger log *log.Logger
bufLog *buflog.BufLog
// Channel for passing reload signals. // Channel for passing reload signals.
sigChan chan os.Signal sigChan chan os.Signal
@ -53,9 +56,12 @@ type App struct {
} }
var ( var (
lo = log.New(os.Stdout, "", log.Ldate|log.Ltime|log.Lshortfile) // Buffered log writer for storing N lines of log entries for the UI.
ko = koanf.New(".") bufLog = buflog.New(5000)
lo = log.New(io.MultiWriter(os.Stdout, bufLog), "",
log.Ldate|log.Ltime|log.Lshortfile)
ko = koanf.New(".")
fs stuffbin.FileSystem fs stuffbin.FileSystem
db *sqlx.DB db *sqlx.DB
queries *Queries queries *Queries
@ -119,7 +125,6 @@ func init() {
// Load settings from DB. // Load settings from DB.
initSettings(queries) initSettings(queries)
} }
func main() { func main() {
@ -132,6 +137,7 @@ func main() {
media: initMediaStore(), media: initMediaStore(),
messengers: make(map[string]messenger.Messenger), messengers: make(map[string]messenger.Messenger),
log: lo, log: lo,
bufLog: bufLog,
} }
_, app.queries = initQueries(queryFilePath, db, fs, true) _, app.queries = initQueries(queryFilePath, db, fs, true)
app.manager = initCampaignManager(app.queries, app.constants, app) app.manager = initCampaignManager(app.queries, app.constants, app)

View file

@ -217,6 +217,12 @@ func handleUpdateSettings(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true}) 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) { func getSettings(app *App) (settings, error) {
var ( var (
b types.JSONText b types.JSONText

View file

@ -78,9 +78,19 @@
icon="file-image-outline" label="Templates"></b-menu-item> icon="file-image-outline" label="Templates"></b-menu-item>
</b-menu-item><!-- campaigns --> </b-menu-item><!-- campaigns -->
<b-menu-item :to="{name: 'settings'}" tag="router-link" <b-menu-item :expanded="activeGroup.settings"
:active="activeItem.settings" :active="activeGroup.settings"
icon="cog-outline" label="Settings"></b-menu-item> 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-list>
</b-menu> </b-menu>
</div> </div>

View file

@ -200,3 +200,6 @@ export const getSettings = async () => http.get('/api/settings',
export const updateSettings = async (data) => http.put('/api/settings', data, export const updateSettings = async (data) => http.put('/api/settings', data,
{ loading: models.settings }); { loading: models.settings });
export const getLogs = async () => http.get('/api/logs',
{ loading: models.logs });

View file

@ -11,6 +11,7 @@ $turquoise: $green;
$red: #ff5722; $red: #ff5722;
$link: $primary; $link: $primary;
$input-placeholder-color: $grey-light; $input-placeholder-color: $grey-light;
$grey-lightest: #eaeaea;
$colors: map-merge($colors, ( $colors: map-merge($colors, (
"turquoise": ($green, $green-invert), "turquoise": ($green, $green-invert),
@ -41,6 +42,11 @@ code {
color: $grey; color: $grey;
} }
pre {
background: none;
border: 1px solid $grey-lightest;
}
ul.no { ul.no {
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
@ -226,7 +232,7 @@ section {
} }
thead th, tbody td { thead th, tbody td {
padding: 15px 10px; padding: 15px 10px;
border-color: #eaeaea; border-color: $grey-lightest;
} }
.actions a { .actions a {
margin: 0 10px; 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 charting lib */
.c3 { .c3 {
.c3-chart-lines .c3-line { .c3-chart-lines .c3-line {

View file

@ -10,6 +10,7 @@ export const models = Object.freeze({
templates: 'templates', templates: 'templates',
media: 'media', media: 'media',
settings: 'settings', settings: 'settings',
logs: 'logs',
}); });
// Ad-hoc URIs that are used outside of vuex requests. // Ad-hoc URIs that are used outside of vuex requests.

View file

@ -77,6 +77,12 @@ const routes = [
meta: { title: 'Settings', group: 'settings' }, meta: { title: 'Settings', group: 'settings' },
component: () => import(/* webpackChunkName: "main" */ '../views/Settings.vue'), 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({ const router = new VueRouter({

View file

@ -43,6 +43,7 @@ export default new Vuex.Store({
[models.templates]: (state) => state[models.templates], [models.templates]: (state) => state[models.templates],
[models.settings]: (state) => state[models.settings], [models.settings]: (state) => state[models.settings],
[models.serverConfig]: (state) => state[models.serverConfig], [models.serverConfig]: (state) => state[models.serverConfig],
[models.logs]: (state) => state[models.logs],
}, },
modules: { modules: {

View file

@ -245,7 +245,7 @@ export default Vue.extend({
this.logs = data; this.logs = data;
Vue.nextTick(() => { 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'); const ref = document.getElementById('import-log');
if (ref) { if (ref) {
ref.scrollTop = ref.scrollHeight; ref.scrollTop = ref.scrollHeight;

View file

@ -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
internal/buflog/buflog.go Normal file
View file

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