Add new 'Logs' page to the UI to view stdout logs
This commit is contained in:
parent
f81d75a787
commit
8dbe30cd26
12 changed files with 172 additions and 8 deletions
|
@ -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)
|
||||
|
|
12
cmd/main.go
12
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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
frontend/src/views/Logs.vue
Normal file
56
frontend/src/views/Logs.vue
Normal 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
50
internal/buflog/buflog.go
Normal 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
|
||||
}
|
Loading…
Reference in a new issue