Proxy Datasette via an iframe
Signed-off-by: AKP <tom@tdpain.net>
This commit is contained in:
parent
a95f759ddd
commit
846813120b
22 changed files with 122 additions and 783 deletions
|
@ -1,6 +1,9 @@
|
|||
package config
|
||||
|
||||
import "github.com/codemicro/analytics/analytics/config/internal/debug"
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/codemicro/analytics/analytics/config/internal/debug"
|
||||
)
|
||||
|
||||
var Debug = debug.Enable
|
||||
|
||||
|
@ -14,6 +17,9 @@ type Config struct {
|
|||
HTTP struct {
|
||||
Address string
|
||||
}
|
||||
Datasette struct {
|
||||
Address string
|
||||
}
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
|
@ -22,10 +28,13 @@ func Load() (*Config, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Println(cl.rawConfigFileContents)
|
||||
|
||||
conf := new(Config)
|
||||
conf.Ingest.Address = asString(cl.withDefault("ingest.address", "127.0.0.1:7500"))
|
||||
conf.HTTP.Address = asString(cl.withDefault("http.address", "127.0.0.1:8080"))
|
||||
conf.Database.DSN = asString(cl.withDefault("database.dsn", "analytics.db"))
|
||||
conf.Datasette.Address = asString(cl.withDefault("datasette.address", "127.0.0.1:8001"))
|
||||
|
||||
return conf, nil
|
||||
}
|
||||
|
|
70
analytics/httpsrv/httpsrv.go
Normal file
70
analytics/httpsrv/httpsrv.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
package httpsrv
|
||||
|
||||
import (
|
||||
"github.com/codemicro/analytics/analytics/config"
|
||||
"github.com/codemicro/analytics/analytics/db"
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/proxy"
|
||||
"github.com/rs/zerolog/log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WebUI struct {
|
||||
conf *config.Config
|
||||
db *db.DB
|
||||
|
||||
app *fiber.App
|
||||
templates *pongo2.TemplateSet
|
||||
}
|
||||
|
||||
func Start(conf *config.Config, db *db.DB) *WebUI {
|
||||
wui := &WebUI{
|
||||
conf: conf,
|
||||
db: db,
|
||||
}
|
||||
|
||||
wui.app = fiber.New(fiber.Config{
|
||||
DisableStartupMessage: !config.Debug,
|
||||
})
|
||||
wui.registerHandlers()
|
||||
|
||||
//wui.templates = pongo2.NewSet("templates", templates.TemplateLoader())
|
||||
|
||||
go func() {
|
||||
if err := wui.app.Listen(conf.HTTP.Address); err != nil {
|
||||
log.Error().Err(err).Msg("HTTP server listen failed")
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
log.Info().Msgf("HTTP server alive on %s", conf.HTTP.Address)
|
||||
return wui
|
||||
}
|
||||
|
||||
func (wui *WebUI) sendTemplate(ctx *fiber.Ctx, fname string, renderCtx pongo2.Context) error {
|
||||
tpl, err := wui.templates.FromFile(fname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tpl.ExecuteBytes(renderCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.Type("html")
|
||||
return ctx.Send(res)
|
||||
}
|
||||
|
||||
func (wui *WebUI) Stop() error {
|
||||
return wui.app.ShutdownWithTimeout(time.Second * 5)
|
||||
}
|
||||
|
||||
func (wui *WebUI) registerHandlers() {
|
||||
wui.app.Get("/", wui.index)
|
||||
wui.app.Use("/ds", func(ctx *fiber.Ctx) error {
|
||||
path := ctx.Path()
|
||||
path = strings.TrimPrefix(path, ctx.Route().Path)
|
||||
return proxy.Do(ctx, wui.conf.Datasette.Address+path)
|
||||
})
|
||||
}
|
14
analytics/httpsrv/index.go
Normal file
14
analytics/httpsrv/index.go
Normal file
|
@ -0,0 +1,14 @@
|
|||
package httpsrv
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var indexPage []byte
|
||||
|
||||
func (wui *WebUI) index(ctx *fiber.Ctx) error {
|
||||
ctx.Set(fiber.HeaderContentType, "text/html")
|
||||
return ctx.Send(indexPage)
|
||||
}
|
26
analytics/httpsrv/index.html
Normal file
26
analytics/httpsrv/index.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Analytics</title>
|
||||
<style>
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
iframe#main {
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="/ds" id="main"></iframe>
|
||||
</body>
|
||||
</html>
|
|
@ -3,8 +3,8 @@ package main
|
|||
import (
|
||||
"github.com/codemicro/analytics/analytics/config"
|
||||
"github.com/codemicro/analytics/analytics/db"
|
||||
"github.com/codemicro/analytics/analytics/httpsrv"
|
||||
"github.com/codemicro/analytics/analytics/ingest"
|
||||
"github.com/codemicro/analytics/analytics/webui"
|
||||
"github.com/rs/zerolog/log"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
@ -33,7 +33,7 @@ func run() error {
|
|||
return err
|
||||
}
|
||||
|
||||
http := webui.Start(conf, database)
|
||||
http := httpsrv.Start(conf, database)
|
||||
|
||||
waitForSignal(syscall.SIGINT)
|
||||
|
||||
|
|
|
@ -1,104 +0,0 @@
|
|||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (wui *WebUI) page_index(ctx *fiber.Ctx) error {
|
||||
return wui.sendTemplate(ctx, "index.html", nil)
|
||||
}
|
||||
|
||||
func (wui *WebUI) partial_activeSessionsTable(ctx *fiber.Ctx) error {
|
||||
ht := &HTMLTable{
|
||||
Path: "/partial/activeSessions",
|
||||
Headers: []*HTMLTableHeader{
|
||||
{"", "", false, true},
|
||||
{"User agent", "", false, false},
|
||||
{"IP", "", false, false},
|
||||
{"Last seen", "last_seen", true, false},
|
||||
},
|
||||
Data: func(sortKey, sortDirection string) ([][]any, error) {
|
||||
var sort string
|
||||
if !(sortKey == "" || sortDirection == "") {
|
||||
sort = sortKey + " " + sortDirection
|
||||
}
|
||||
sessions, err := wui.db.GetSessionsWithActivityAfter(30, sort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res [][]any
|
||||
for _, sess := range sessions {
|
||||
ua, _ := pongo2.ApplyFilter("truncatechars", pongo2.AsValue(sess.UserAgent), pongo2.AsValue(40))
|
||||
ua, _ = pongo2.ApplyFilter("default", ua, unsetValue)
|
||||
res = append(res, []any{
|
||||
fmt.Sprintf(`<a href="/session?id=%s">[Link]</a>`, sess.ID),
|
||||
ua,
|
||||
getValue(pongo2.ApplyFilter("truncatechars", pongo2.AsValue(sess.IPAddr), pongo2.AsValue(30))),
|
||||
getValue(pongo2.ApplyFilter("shortTimeSince", pongo2.AsValue(sess.LastSeen), nil)),
|
||||
})
|
||||
}
|
||||
|
||||
return res, nil
|
||||
},
|
||||
DefaultSortKey: "last_seen",
|
||||
DefaultSortDirection: "desc",
|
||||
ShowNumberOfEntries: true,
|
||||
}
|
||||
return wui.renderHTMLTable(ctx, ht)
|
||||
}
|
||||
|
||||
func (wui *WebUI) partial_topURLs(ctx *fiber.Ctx) error {
|
||||
nStr := ctx.Query("n")
|
||||
var n int
|
||||
if nStr != "" {
|
||||
n, _ = strconv.Atoi(nStr)
|
||||
}
|
||||
|
||||
hoursStr := ctx.Query("hours")
|
||||
var hours int
|
||||
if hoursStr != "" {
|
||||
hours, _ = strconv.Atoi(hoursStr)
|
||||
}
|
||||
|
||||
ht := &HTMLTable{
|
||||
Headers: []*HTMLTableHeader{
|
||||
{"Count", "", false, false},
|
||||
{"Host", "", false, false},
|
||||
{"Path", "", false, false},
|
||||
},
|
||||
Data: func(sortKey, sortDirection string) ([][]any, error) {
|
||||
var counts []struct {
|
||||
Host string
|
||||
URI string
|
||||
Count int
|
||||
}
|
||||
q := wui.db.DB.NewSelect().
|
||||
ColumnExpr(`"host", "uri", COUNT(*) as "count"`).
|
||||
Table("requests").
|
||||
GroupExpr(`"host", "uri"`).
|
||||
OrderExpr(`"count" DESC`)
|
||||
if n > 0 {
|
||||
q = q.Limit(n)
|
||||
}
|
||||
if hours > 0 {
|
||||
q = q.Where(fmt.Sprintf(`datetime() < datetime(time, '+%d hours')`, hours))
|
||||
}
|
||||
if err := q.Scan(context.Background(), &counts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res [][]any
|
||||
for _, c := range counts {
|
||||
res = append(res, []any{c.Count, c.Host, c.URI})
|
||||
}
|
||||
|
||||
return res, nil
|
||||
},
|
||||
}
|
||||
return wui.renderHTMLTable(ctx, ht)
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
<p>{{ sessions|length }} active session{% if sessions|length != 0%}s{% endif %}</p>
|
||||
{% if sessions|length != 0 %}
|
||||
<div class="table">
|
||||
<table>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>User agent</th>
|
||||
<th>IP</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
{% for session in sessions %}
|
||||
<tr>
|
||||
<td><a href="/session?id={{ session.ID }}">[Link]</a></td>
|
||||
<td title="{{ session.UserAgent }}">{{ session.UserAgent | truncatechars:40 }}</td>
|
||||
<td title="{{ session.IPAddr }}">{{ session.IPAddr | truncatechars:30 }}</td>
|
||||
<td>{{ session.LastSeen | shortTimeSince }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
|
@ -1,31 +0,0 @@
|
|||
<div class="table" hx-target="this">
|
||||
<table>
|
||||
<tr>
|
||||
{% for header in headers %}
|
||||
<th>
|
||||
{% if header.Sortable %}
|
||||
<a href="#" hx-get="{{ path }}?sortKey={{ header.Slug }}{% if sortKey and header.Slug == sortKey %}&sortDir={% if sortDirection == "asc" %}desc{% elif sortDirection == "desc" %}asc{% endif %}{% endif %}">
|
||||
{% endif %}
|
||||
{{ header.Name }}{% if sortKey %}{% if header.Slug == sortKey %} {% if sortDirection == "asc" %}▲{% else %}▼{% endif %}{% endif %}{% endif %}
|
||||
{% if header.Sortable %}</a>{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% for item in row %}
|
||||
{% with headers[forloop.Counter - 1] as header %}
|
||||
{% if header.Safe %}
|
||||
<td>{{ item | safe }}</td>
|
||||
{% else %}
|
||||
<td>{{ item }}</td>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if showNumberOfEntries %}
|
||||
<span style="margin-top: 5px" class="italic">{{ rows | length }} records</span>
|
||||
{% endif %}
|
||||
</div>
|
|
@ -1,17 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }}</title>
|
||||
<link rel="stylesheet" href="/assets/css/main.css">
|
||||
<script src="https://unpkg.com/htmx.org@1.8.6"></script>
|
||||
{% block head %}{% endblock%}
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Analytics</h1>
|
||||
{% block main %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -1,23 +0,0 @@
|
|||
{% extends "extendable/base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<h2>Top 10 URLs (past 24 hours)</h2>
|
||||
|
||||
<div hx-get="/partial/topURLs?n=10&hours=24"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
|
||||
<h2>Active sessions</h2>
|
||||
|
||||
<div hx-get="/partial/activeSessions?minutes=30"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
|
||||
<a href="/sessions">[See all]</a>
|
||||
{% endblock %}
|
|
@ -1,14 +0,0 @@
|
|||
{% extends "extendable/base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<a href="/">[Home]</a>
|
||||
|
||||
<h2>All sessions</h2>
|
||||
|
||||
<div hx-get="/partial/listSessions"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,31 +0,0 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"io"
|
||||
"path"
|
||||
)
|
||||
|
||||
//go:embed *
|
||||
var templateFS embed.FS
|
||||
|
||||
func TemplateLoader() pongo2.TemplateLoader {
|
||||
return &templateLoader{templateFS}
|
||||
}
|
||||
|
||||
type templateLoader struct {
|
||||
embed.FS
|
||||
}
|
||||
|
||||
func (tl *templateLoader) Abs(base, name string) string {
|
||||
return path.Join(path.Dir(base), name)
|
||||
}
|
||||
|
||||
func (tl *templateLoader) Get(name string) (io.Reader, error) {
|
||||
f, err := tl.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
{% extends "extendable/base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<a href="/">[Home]</a>
|
||||
|
||||
<h2>Logs from session</h2>
|
||||
|
||||
<p>
|
||||
Session ID: {{ session.ID }}<br>
|
||||
IP address: {{ session.IPAddr }}<br>
|
||||
User agent: {% if session.UserAgent %}{{ session.UserAgent }}{% else %}<span class="italic">unset</span>{% endif %}
|
||||
</p>
|
||||
|
||||
<div hx-get="/partial/sessionLogs/{{ session.ID }}"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,20 +0,0 @@
|
|||
{% extends "extendable/base.html" %}
|
||||
|
||||
{% block main %}
|
||||
<a href="/">[Home]</a>
|
||||
|
||||
<h2>Request detail</h2>
|
||||
|
||||
<p>
|
||||
ID: {{ request.ID }}<br>
|
||||
Time: {{ request.Time | date:"2006-01-02 15:04:05" }}<br>
|
||||
IP address: {{ request.IPAddr }}<br>
|
||||
Host: {{ request.Host }}<br>
|
||||
Raw path: {{ request.RawURI }}<br>
|
||||
Normalised path: {{ request.URI }}<br>
|
||||
Referer: {% if request.Referer %}{{ request.Referer }}{% else %}<span class="italic">unset</span>{% endif %}<br>
|
||||
User agent: {% if request.UserAgent %}{{ request.UserAgent }}{% else %}<span class="italic">unset</span>{% endif %}<br>
|
||||
Status code: {{ request.StatusCode }}<br>
|
||||
Session: <a href="/session?id={{ request.SessionID }}">{{ request.SessionID }}</a>
|
||||
</p>
|
||||
{% endblock %}
|
|
@ -1,51 +0,0 @@
|
|||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func (wui *WebUI) page_listSessions(ctx *fiber.Ctx) error {
|
||||
return wui.sendTemplate(ctx, "list-sessions.html", nil)
|
||||
}
|
||||
|
||||
func (wui *WebUI) partial_listSessions(ctx *fiber.Ctx) error {
|
||||
ht := &HTMLTable{
|
||||
Path: "/partial/listSessions",
|
||||
Headers: []*HTMLTableHeader{
|
||||
{"", "", false, true},
|
||||
{"User agent", "", false, false},
|
||||
{"IP", "", false, false},
|
||||
{"Last seen", "last_seen", true, false},
|
||||
},
|
||||
Data: func(sortKey, sortDirection string) ([][]any, error) {
|
||||
var sort string
|
||||
if !(sortKey == "" || sortDirection == "") {
|
||||
sort = sortKey + " " + sortDirection
|
||||
}
|
||||
sessions, err := wui.db.GetSessionsWithActivityAfter(60*24, sort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res [][]any
|
||||
for _, sess := range sessions {
|
||||
ua, _ := pongo2.ApplyFilter("truncatechars", pongo2.AsValue(sess.UserAgent), pongo2.AsValue(40))
|
||||
ua, _ = pongo2.ApplyFilter("default", ua, unsetValue)
|
||||
res = append(res, []any{
|
||||
fmt.Sprintf(`<a href="/session?id=%s">[Link]</a>`, sess.ID),
|
||||
ua,
|
||||
getValue(pongo2.ApplyFilter("truncatechars", pongo2.AsValue(sess.IPAddr), pongo2.AsValue(30))),
|
||||
getValue(pongo2.ApplyFilter("shortTimeSince", pongo2.AsValue(sess.LastSeen), nil)),
|
||||
})
|
||||
}
|
||||
|
||||
return res, nil
|
||||
},
|
||||
DefaultSortKey: "last_seen",
|
||||
DefaultSortDirection: "desc",
|
||||
ShowNumberOfEntries: true,
|
||||
}
|
||||
return wui.renderHTMLTable(ctx, ht)
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"github.com/codemicro/analytics/analytics/db/models"
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func (wui *WebUI) page_requestDetail(ctx *fiber.Ctx) error {
|
||||
id := ctx.Query("id")
|
||||
if id == "" {
|
||||
return fiber.ErrBadRequest
|
||||
}
|
||||
|
||||
request := new(models.Request)
|
||||
if err := wui.db.DB.NewSelect().Model(request).Where("id = ?", id).Scan(context.Background(), request); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fiber.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
pctx := pongo2.Context{
|
||||
"request": request,
|
||||
}
|
||||
|
||||
return wui.sendTemplate(ctx, "request-detail.html", pctx)
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
body {
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
margin-bottom: 10px;
|
||||
font-size: 18px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
div.pt {
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
div.card-deck {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
div.card-deck div.post {
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
}
|
||||
div.card-deck div.post:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
div.card-deck {
|
||||
flex-direction: column;
|
||||
}
|
||||
div.card-deck div.card {
|
||||
margin-right: unset;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
div.card-deck div.card:last-child {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
div.table table {
|
||||
width: 60%;
|
||||
white-space: nowrap;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 10px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
div.table table tr:nth-child(even) {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
div.table table tr:hover {
|
||||
background-color: #dddddd;
|
||||
}
|
||||
div.table table td, div.table table th {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
div.table table th {
|
||||
border-bottom: 3px solid black;
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=main.css.map */
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"sourceRoot":"","sources":["main.scss"],"names":[],"mappings":"AAAA;EACE;EAEA;EACA;EAEA;EACA;;;AAgBF;EAEE;;;AAKF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;AAIA;EACE;EACA,cAJY;;AAOd;EACE;;AAGF;EAfF;IAgBI;;EAEA;IAAW;IAAqB,eAdpB;;EAeZ;IAAsB,eAfV;;;;AAoBd;EACE;EACA;EACA;EACA;EAEA;;AAGE;EAAoB;;AACpB;EAAU;;AAGZ;EACE;;AAGF;EACE;EACA","file":"main.css"}
|
|
@ -1,85 +0,0 @@
|
|||
body {
|
||||
line-height: 1.3;
|
||||
|
||||
margin: 0;
|
||||
margin-bottom: 10px;
|
||||
|
||||
font-size: 18px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
@mixin container {
|
||||
width: 55%;
|
||||
margin: 0 auto;
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
width: 98%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1100px) and (min-width: 801px) {
|
||||
width: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
//@include container;
|
||||
padding: 15px;
|
||||
//padding-top: 15px;
|
||||
//padding-bottom: 15px;
|
||||
}
|
||||
|
||||
div.pt {
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
div.card-deck {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
$margin-size: 10px;
|
||||
|
||||
div.post {
|
||||
flex: 1;
|
||||
margin-right: $margin-size;
|
||||
}
|
||||
|
||||
div.post:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
flex-direction: column;
|
||||
|
||||
div.card { margin-right: unset; margin-bottom: $margin-size; }
|
||||
div.card:last-child { margin-bottom: $margin-size; }
|
||||
}
|
||||
}
|
||||
|
||||
div.table {
|
||||
table {
|
||||
width: 60%;
|
||||
white-space: nowrap;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 10px;
|
||||
|
||||
overflow-y: scroll;
|
||||
|
||||
tr {
|
||||
&:nth-child(even) { background-color: #f2f2f2; }
|
||||
&:hover { background-color: #dddddd; }
|
||||
}
|
||||
|
||||
td, th {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
th {
|
||||
border-bottom: 3px solid black;
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
package webui
|
||||
|
||||
import (
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type HTMLTable struct {
|
||||
Path string
|
||||
Headers []*HTMLTableHeader
|
||||
Data func(sortKey, sortDirection string) ([][]any, error)
|
||||
DefaultSortKey string
|
||||
DefaultSortDirection string
|
||||
ShowNumberOfEntries bool
|
||||
}
|
||||
|
||||
type HTMLTableHeader struct {
|
||||
Name string
|
||||
Slug string
|
||||
Sortable bool
|
||||
Safe bool
|
||||
}
|
||||
|
||||
var unsetValue = pongo2.AsSafeValue(`<span class="italic">unset</span>`)
|
||||
|
||||
func (wui *WebUI) renderHTMLTable(ctx *fiber.Ctx, ht *HTMLTable) error {
|
||||
sortKey := ctx.Query("sortKey")
|
||||
sortDirection := strings.ToLower(ctx.Query("sortDir"))
|
||||
|
||||
{
|
||||
var validatedSortKey string
|
||||
for _, header := range ht.Headers {
|
||||
if strings.EqualFold(header.Slug, sortKey) && header.Sortable {
|
||||
validatedSortKey = header.Slug
|
||||
break
|
||||
}
|
||||
}
|
||||
sortKey = validatedSortKey
|
||||
}
|
||||
|
||||
if sortKey == "" {
|
||||
sortKey = ht.DefaultSortKey
|
||||
}
|
||||
|
||||
if sortDirection == "" || !(sortDirection == "asc" || sortDirection == "desc") {
|
||||
if sortKey == "" {
|
||||
sortDirection = ""
|
||||
} else {
|
||||
sortDirection = ht.DefaultSortDirection
|
||||
}
|
||||
}
|
||||
|
||||
data, err := ht.Data(sortKey, sortDirection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Type("html")
|
||||
return wui.sendTemplate(ctx, "components/table.html", pongo2.Context{
|
||||
"path": ht.Path,
|
||||
"headers": ht.Headers,
|
||||
"rows": data,
|
||||
"sortKey": sortKey,
|
||||
"sortDirection": sortDirection,
|
||||
"showNumberOfEntries": ht.ShowNumberOfEntries,
|
||||
})
|
||||
}
|
||||
|
||||
func getValue(v *pongo2.Value, _ *pongo2.Error) *pongo2.Value {
|
||||
return v
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/codemicro/analytics/analytics/db/models"
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"html"
|
||||
)
|
||||
|
||||
func (wui *WebUI) page_logsFromSession(ctx *fiber.Ctx) error {
|
||||
id := ctx.Query("id")
|
||||
|
||||
if id == "" {
|
||||
return fiber.ErrBadRequest
|
||||
}
|
||||
|
||||
session := new(models.Session)
|
||||
if err := wui.db.DB.NewSelect().Model(session).Where("id = ?", id).Scan(context.Background(), session); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fiber.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
pctx := pongo2.Context{
|
||||
"session": session,
|
||||
}
|
||||
|
||||
return wui.sendTemplate(ctx, "logs-from-session.html", pctx)
|
||||
}
|
||||
|
||||
func (wui *WebUI) partial_logsFromSession(ctx *fiber.Ctx) error {
|
||||
id := ctx.Params("id")
|
||||
pa := fmt.Sprintf("/partial/sessionLogs/%s", html.EscapeString(id))
|
||||
|
||||
ht := &HTMLTable{
|
||||
Path: pa,
|
||||
Headers: []*HTMLTableHeader{
|
||||
{"Datetime", "time", true, false},
|
||||
{"Host", "", false, false},
|
||||
{"Raw path", "raw_uri", true, false},
|
||||
{"Status", "status_code", true, false},
|
||||
{"Referer", "", false, false},
|
||||
{"", "", false, true},
|
||||
},
|
||||
Data: func(sortKey, sortDirection string) ([][]any, error) {
|
||||
var reqs []*models.Request
|
||||
q := wui.db.DB.NewSelect().Model(&reqs).Where("session_id = ?", id)
|
||||
if sortKey != "" {
|
||||
q = q.Order(sortKey + " " + sortDirection)
|
||||
}
|
||||
if err := q.Scan(context.Background(), &reqs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res [][]any
|
||||
for _, request := range reqs {
|
||||
res = append(res, []any{
|
||||
getValue(pongo2.ApplyFilter("date", pongo2.AsValue(request.Time), pongo2.AsValue("2006-01-02 15:04:05"))),
|
||||
request.Host,
|
||||
request.RawURI,
|
||||
request.StatusCode,
|
||||
getValue(pongo2.ApplyFilter("default", pongo2.AsValue(request.Referer), unsetValue)),
|
||||
fmt.Sprintf(`<a href="/request?id=%s">[Link]</a>`, request.ID),
|
||||
})
|
||||
}
|
||||
|
||||
return res, nil
|
||||
},
|
||||
DefaultSortKey: "time",
|
||||
DefaultSortDirection: "desc",
|
||||
ShowNumberOfEntries: true,
|
||||
}
|
||||
return wui.renderHTMLTable(ctx, ht)
|
||||
}
|
|
@ -1,116 +0,0 @@
|
|||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"github.com/codemicro/analytics/analytics/config"
|
||||
"github.com/codemicro/analytics/analytics/db"
|
||||
"github.com/codemicro/analytics/analytics/webui/internal/templates"
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
"github.com/rs/zerolog/log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WebUI struct {
|
||||
conf *config.Config
|
||||
db *db.DB
|
||||
|
||||
app *fiber.App
|
||||
templates *pongo2.TemplateSet
|
||||
}
|
||||
|
||||
func init() {
|
||||
pongo2.RegisterFilter("shortTimeSince", func(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
|
||||
tn := time.Now().UTC()
|
||||
t := in.Time()
|
||||
dur := tn.Sub(t).Round(time.Second)
|
||||
|
||||
var (
|
||||
qty int
|
||||
descriptor string
|
||||
)
|
||||
|
||||
if int(dur.Minutes()) != 0 {
|
||||
qty = int(dur.Minutes())
|
||||
descriptor = "minute"
|
||||
} else if int(dur.Seconds()) > 30 {
|
||||
qty = int(dur.Seconds())
|
||||
descriptor = "second"
|
||||
} else {
|
||||
return pongo2.AsValue("just now"), nil
|
||||
}
|
||||
|
||||
if qty != 1 {
|
||||
descriptor += "s"
|
||||
}
|
||||
|
||||
return pongo2.AsValue(fmt.Sprintf("%d %s ago", qty, descriptor)), nil
|
||||
})
|
||||
}
|
||||
|
||||
func Start(conf *config.Config, db *db.DB) *WebUI {
|
||||
wui := &WebUI{
|
||||
conf: conf,
|
||||
db: db,
|
||||
}
|
||||
|
||||
wui.app = fiber.New(fiber.Config{
|
||||
DisableStartupMessage: !config.Debug,
|
||||
})
|
||||
wui.registerHandlers()
|
||||
|
||||
wui.templates = pongo2.NewSet("templates", templates.TemplateLoader())
|
||||
|
||||
go func() {
|
||||
if err := wui.app.Listen(conf.HTTP.Address); err != nil {
|
||||
log.Error().Err(err).Msg("HTTP server listen failed")
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
log.Info().Msgf("HTTP server alive on %s", conf.HTTP.Address)
|
||||
return wui
|
||||
}
|
||||
|
||||
func (wui *WebUI) sendTemplate(ctx *fiber.Ctx, fname string, renderCtx pongo2.Context) error {
|
||||
tpl, err := wui.templates.FromFile(fname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tpl.ExecuteBytes(renderCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.Type("html")
|
||||
return ctx.Send(res)
|
||||
}
|
||||
|
||||
func (wui *WebUI) Stop() error {
|
||||
return wui.app.ShutdownWithTimeout(time.Second * 5)
|
||||
}
|
||||
|
||||
//go:embed static/*
|
||||
var static embed.FS
|
||||
|
||||
func (wui *WebUI) registerHandlers() {
|
||||
wui.app.Get("/", wui.page_index)
|
||||
wui.app.Get("/partial/activeSessions", wui.partial_activeSessionsTable)
|
||||
|
||||
wui.app.Get("/session", wui.page_logsFromSession)
|
||||
wui.app.Get("/partial/sessionLogs/:id", wui.partial_logsFromSession)
|
||||
|
||||
wui.app.Get("/partial/topURLs", wui.partial_topURLs)
|
||||
|
||||
wui.app.Get("/request", wui.page_requestDetail)
|
||||
|
||||
wui.app.Get("/sessions", wui.page_listSessions)
|
||||
wui.app.Get("/partial/listSessions", wui.partial_listSessions)
|
||||
|
||||
wui.app.Use("/", filesystem.New(filesystem.Config{
|
||||
Root: http.FS(static),
|
||||
PathPrefix: "static",
|
||||
}))
|
||||
}
|
Reference in a new issue