many things

Signed-off-by: AKP <tom@tdpain.net>
This commit is contained in:
akp 2023-04-01 23:48:39 +01:00
parent 93a374d8f2
commit 91d16e7339
No known key found for this signature in database
GPG key ID: AA5726202C8879B7
17 changed files with 588 additions and 6 deletions

View file

@ -9,6 +9,7 @@ import (
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/sqlitedialect"
"github.com/uptrace/bun/driver/sqliteshim"
"github.com/uptrace/bun/extra/bundebug"
"github.com/uptrace/bun/migrate"
)
@ -23,6 +24,9 @@ func New(conf *config.Config) (*DB, error) {
}
db := bun.NewDB(sqldb, sqlitedialect.New())
if config.Debug {
db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
}
log.Info().Msg("migrating database")
mig := migrate.NewMigrator(db, migrations.Migrations)

View file

@ -1,7 +1,9 @@
package models
import (
"fmt"
"github.com/uptrace/bun"
"time"
)
type Session struct {
@ -10,4 +12,10 @@ type Session struct {
ID string `bun:",pk"`
UserAgent string `bun:"type:VARCHAR COLLATE NOCASE"`
IPAddr string
LastSeen time.Time `bun:",scanonly"`
}
func (s *Session) String() string {
return fmt.Sprintf("ID:%s UA:%#v IP:%s LastSeen:%s", s.ID, s.UserAgent, s.IPAddr, s.LastSeen.Format(time.DateTime))
}

57
analytics/webui/index.go Normal file
View file

@ -0,0 +1,57 @@
package webui
import (
"context"
"fmt"
"github.com/codemicro/analytics/analytics/db/models"
"github.com/flosch/pongo2/v6"
"github.com/gofiber/fiber/v2"
)
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 sessions []*models.Session
q := wui.db.DB.NewSelect().
Model((*models.Session)(nil)).
ColumnExpr("*").
ColumnExpr(`(select max("time") as "time" from requests where session_id = "session"."id") as "last_seen"`).
Where(`datetime() < datetime("last_seen", '+30 minutes')`)
if sortKey != "" {
q = q.Order(sortKey + " " + sortDirection)
}
if err := q.Scan(context.Background(), &sessions); 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)
}

View file

@ -0,0 +1,21 @@
<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 %}

View file

@ -0,0 +1,31 @@
<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>

View file

@ -0,0 +1,17 @@
<!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>

View file

@ -0,0 +1,14 @@
{% extends "extendable/base.html" %}
{% block main %}
<h2>Active sessions</h2>
<div hx-get="/partial/activeSessions"
hx-trigger="load"
hx-swap="outerHTML"
>
Loading...
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,31 @@
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
}

View file

@ -0,0 +1,22 @@
{% extends "extendable/base.html" %}
{% block main %}
<a href="/">[< Back]</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>
</div>
{% endblock %}

View file

@ -0,0 +1,76 @@
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")
pctx := make(pongo2.Context)
if id == "" {
return ctx.RedirectBack("/")
}
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["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},
},
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)),
})
}
return res, nil
},
DefaultSortKey: "time",
DefaultSortDirection: "desc",
ShowNumberOfEntries: true,
}
return wui.renderHTMLTable(ctx, ht)
}

View file

@ -0,0 +1,65 @@
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;
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 */

View file

@ -0,0 +1 @@
{"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;EAEA;;AAGE;EAAoB;;AACpB;EAAU;;AAGZ;EACE;;AAGF;EACE;EACA","file":"main.css"}

View file

@ -0,0 +1,85 @@
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;
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;
}
}
}

74
analytics/webui/tables.go Normal file
View file

@ -0,0 +1,74 @@
package webui
import (
"fmt"
"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") {
fmt.Println("ere", sortDirection, sortKey)
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
}

View file

@ -1,10 +1,16 @@
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"
)
@ -12,7 +18,37 @@ type WebUI struct {
conf *config.Config
db *db.DB
app *fiber.App
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 {
@ -20,23 +56,54 @@ func Start(conf *config.Config, db *db.DB) *WebUI {
conf: conf,
db: db,
}
wui.app = fiber.New()
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("/", func(ctx *fiber.Ctx) error {
return ctx.SendString("Hello! This is the HTTP server.")
})
}
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.Use("/", filesystem.New(filesystem.Config{
Root: http.FS(static),
PathPrefix: "static",
}))
}

3
go.mod
View file

@ -13,6 +13,8 @@ require (
require (
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.14.1 // indirect
github.com/flosch/pongo2/v6 v6.0.0 // indirect
github.com/gofiber/fiber/v2 v2.43.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
@ -31,6 +33,7 @@ require (
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/tinylib/msgp v1.1.8 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/uptrace/bun/extra/bundebug v1.1.12 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.45.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect

6
go.sum
View file

@ -6,6 +6,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.43.0 h1:yit3E4kHf178B60p5CQBa/3v+WVuziWMa/G2ZNyLJB0=
github.com/gofiber/fiber/v2 v2.43.0/go.mod h1:mpS1ZNE5jU+u+BA4FbM+KKnUzJ4wzTK+FT2tG3tU+6I=
@ -68,6 +72,8 @@ github.com/uptrace/bun/dialect/sqlitedialect v1.1.12 h1:Ud31nqZmebcQpl151nb108+v
github.com/uptrace/bun/dialect/sqlitedialect v1.1.12/go.mod h1:Pwg7s31BdF3PMBlWTnYkEn2I9ASsvatt1Ln/AERCTV4=
github.com/uptrace/bun/driver/sqliteshim v1.1.12 h1:GMbSa7Pjjk4kjF8XURz5uMLe2PbN98e6t00sp0rx2Eo=
github.com/uptrace/bun/driver/sqliteshim v1.1.12/go.mod h1:u67g2ewzoMDCCAqjliHAM/BJjEXfoExXlFXhx3TnXRs=
github.com/uptrace/bun/extra/bundebug v1.1.12 h1:y8nrHvo7TUCR91kXngWuF7Bk0E1nCTsWzYL1CDEriTo=
github.com/uptrace/bun/extra/bundebug v1.1.12/go.mod h1:psjCrCMf5JaAyivW/A8MDBW5MwIy/jZFBCkIaBgabtM=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.45.0 h1:zPkkzpIn8tdHZUrVa6PzYd0i5verqiPSkgTd3bSUcpA=