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.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)
|
||||||
|
|
12
cmd/main.go
12
cmd/main.go
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 :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"
|
<b-menu-item :to="{name: 'settings'}" tag="router-link"
|
||||||
:active="activeItem.settings"
|
:active="activeItem.settings"
|
||||||
icon="cog-outline" label="Settings"></b-menu-item>
|
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>
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
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