we now got websockets baby

This commit is contained in:
2026-01-26 00:29:57 +11:00
parent ce524702f0
commit a3dafa592b
23 changed files with 621 additions and 312 deletions

View File

@@ -3,7 +3,7 @@ testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = ["--htmxlog"]
args_bin = ["--dev"]
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./cmd/oslstats"
delay = 1000
@@ -14,7 +14,7 @@ tmp_dir = "tmp"
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "templ"]
include_ext = ["go", "templ", "js"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"

View File

@@ -35,6 +35,12 @@ func setupHttpServer(
ignoredPaths := []string{
"/static/css/output.css",
"/static/favicon.ico",
"/static/js/popups.js",
"/static/js/theme.js",
"/static/vendored/htmx@2.0.8.min.js",
"/static/vendored/htmx-ext-ws.min.js",
"/static/vendored/alpinejs@3.15.4.min.js",
"/ws/notifications",
}
auth, err := setupAuth(cfg.HWSAuth, logger, bun, httpServer, ignoredPaths)
@@ -62,7 +68,7 @@ func setupHttpServer(
return nil, errors.Wrap(err, "addRoutes")
}
err = addMiddleware(httpServer, auth, cfg.Flags)
err = addMiddleware(httpServer, auth, cfg)
if err != nil {
return nil, errors.Wrap(err, "addMiddleware")
}

View File

@@ -3,6 +3,7 @@ package main
import (
"context"
"net/http"
"strconv"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/hwsauth"
@@ -17,12 +18,12 @@ import (
func addMiddleware(
server *hws.Server,
auth *hwsauth.Authenticator[*db.User, bun.Tx],
flags *config.Flags,
cfg *config.Config,
) error {
err := server.AddMiddleware(
auth.Authenticate(),
htmxLog(flags.HTMXLog),
devMode(cfg),
)
if err != nil {
return errors.Wrap(err, "server.AddMiddleware")
@@ -30,12 +31,20 @@ func addMiddleware(
return nil
}
func htmxLog(htmxlog bool) hws.Middleware {
func devMode(cfg *config.Config) hws.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), contexts.HTMXLogKey, htmxlog)
req := r.WithContext(ctx)
next.ServeHTTP(w, req)
if cfg.Flags.DevMode {
devInfo := contexts.DevInfo{
WebsocketBase: "ws://" + cfg.HWS.Host + ":" + strconv.FormatUint(cfg.HWS.Port, 10),
HTMXLog: true,
}
ctx := context.WithValue(r.Context(), contexts.DevModeKey, devInfo)
req := r.WithContext(ctx)
next.ServeHTTP(w, req)
return
}
next.ServeHTTP(w, r)
},
)
}

View File

@@ -16,7 +16,7 @@ import (
)
func addRoutes(
server *hws.Server,
s *hws.Server,
staticFS *http.FileSystem,
cfg *config.Config,
conn *bun.DB,
@@ -25,41 +25,42 @@ func addRoutes(
discordAPI *discord.APIClient,
) error {
// Create the routes
routes := []hws.Route{
pageroutes := []hws.Route{
{
Path: "/static/",
Method: hws.MethodGET,
Handler: http.StripPrefix("/static/", handlers.StaticFS(staticFS, server)),
Handler: http.StripPrefix("/static/", handlers.StaticFS(staticFS, s)),
},
{
Path: "/",
Method: hws.MethodGET,
Handler: handlers.Index(server),
Handler: handlers.Index(s),
},
{
Path: "/login",
Method: hws.MethodGET,
Handler: auth.LogoutReq(handlers.Login(server, cfg, store, discordAPI)),
Handler: auth.LogoutReq(handlers.Login(s, cfg, store, discordAPI)),
},
{
Path: "/auth/callback",
Method: hws.MethodGET,
Handler: auth.LogoutReq(handlers.Callback(server, auth, conn, cfg, store, discordAPI)),
Handler: auth.LogoutReq(handlers.Callback(s, auth, conn, cfg, store, discordAPI)),
},
{
Path: "/register",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: auth.LogoutReq(handlers.Register(server, auth, conn, cfg, store)),
Handler: auth.LogoutReq(handlers.Register(s, auth, conn, cfg, store)),
},
{
Path: "/logout",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: auth.LoginReq(handlers.Logout(server, auth, conn, discordAPI)),
Handler: auth.LoginReq(handlers.Logout(s, auth, conn, discordAPI)),
},
{
Path: "/test",
Path: "/notification-tester",
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
Handler: handlers.Test(server),
Handler: handlers.NotifyTester(s),
// TODO: add login protection
},
}
@@ -67,12 +68,23 @@ func addRoutes(
{
Path: "/htmx/isusernameunique",
Method: hws.MethodPOST,
Handler: handlers.IsUsernameUnique(server, conn, cfg, store),
Handler: handlers.IsUsernameUnique(s, conn, cfg, store),
},
}
wsRoutes := []hws.Route{
{
Path: "/ws/notifications",
Method: hws.MethodGET,
Handler: handlers.NotificationWS(s, cfg),
},
}
routes := append(pageroutes, htmxRoutes...)
routes = append(routes, wsRoutes...)
// Register the routes with the server
err := server.AddRoutes(append(routes, htmxRoutes...)...)
err := s.AddRoutes(routes...)
if err != nil {
return errors.Wrap(err, "server.AddRoutes")
}

View File

@@ -73,8 +73,9 @@ func run(ctx context.Context, w io.Writer, cfg *config.Config) error {
wg.Go(func() {
<-ctx.Done()
shutdownCtx := context.Background()
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 60*time.Second)
defer cancel()
logger.Info().Msg("Shut down requested, waiting 60 seconds...")
err := httpServer.Shutdown(shutdownCtx)
if err != nil {
logger.Error().Err(err).Msg("Graceful shutdown failed")

6
go.mod
View File

@@ -6,9 +6,11 @@ require (
git.haelnorr.com/h/golib/env v0.9.1
git.haelnorr.com/h/golib/ezconf v0.1.1
git.haelnorr.com/h/golib/hlog v0.10.4
git.haelnorr.com/h/golib/hws v0.3.2
git.haelnorr.com/h/golib/hws v0.4.0
git.haelnorr.com/h/golib/hwsauth v0.5.2
git.haelnorr.com/h/golib/notify v0.1.0
github.com/a-h/templ v0.3.977
github.com/coder/websocket v1.8.14
github.com/joho/godotenv v1.5.1
github.com/pkg/errors v0.9.1
github.com/uptrace/bun v1.2.16
@@ -18,7 +20,7 @@ require (
require (
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
)
require (

12
go.sum
View File

@@ -6,18 +6,22 @@ git.haelnorr.com/h/golib/ezconf v0.1.1 h1:4euTSDb9jvuQQkVq+x5gHoYPYyUZPWxoOSlWCI
git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8=
git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ=
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
git.haelnorr.com/h/golib/hws v0.3.2 h1:OSwCwVxDerermyuoxrAYgt+i1zzwF/0Baoy8zmCxtuQ=
git.haelnorr.com/h/golib/hws v0.3.2/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo=
git.haelnorr.com/h/golib/hws v0.4.0 h1:T2JfRz4zpgsNXj0Vyfzxdf/60Tee/7H30osFmr5jDh0=
git.haelnorr.com/h/golib/hws v0.4.0/go.mod h1:UqB83p9lGjidDkk0pWRqxxOFrCkg8t+9J6uGtBOjNLo=
git.haelnorr.com/h/golib/hwsauth v0.5.2 h1:K4McXMEHtI5o4fAL3AZrmaMkwORNqSTV3MM6BExNKag=
git.haelnorr.com/h/golib/hwsauth v0.5.2/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ=
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
git.haelnorr.com/h/golib/jwt v0.10.1/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
git.haelnorr.com/h/golib/notify v0.1.0 h1:xdf6zd21F6n+SuGTeJiuLNMf6zFXMvwpKD0gmNq8N10=
git.haelnorr.com/h/golib/notify v0.1.0/go.mod h1:ARqaRmCYb8LMURhDM75sG+qX+YpqXmUVeAtacwjHjBc=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -71,8 +75,8 @@ go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwEx
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -12,7 +12,7 @@ type Flags struct {
ShowEnv bool
GenEnv string
EnvFile string
HTMXLog bool
DevMode bool
// Database reset (destructive)
ResetDB bool
@@ -34,7 +34,7 @@ func SetupFlags() (*Flags, error) {
showEnv := flag.Bool("showenv", false, "Print all environment variable values and their documentation")
genEnv := flag.String("genenv", "", "Generate a .env file with all environment variables (specify filename)")
envfile := flag.String("envfile", ".env", "Specify a .env file to use for the configuration")
htmxlog := flag.Bool("htmxlog", false, "Run the server with all HTMX events logged")
devMode := flag.Bool("dev", false, "Run the server in dev mode")
// Database reset (destructive)
resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)")
@@ -78,7 +78,7 @@ func SetupFlags() (*Flags, error) {
ShowEnv: *showEnv,
GenEnv: *genEnv,
EnvFile: *envfile,
HTMXLog: *htmxlog,
DevMode: *devMode,
ResetDB: *resetDB,
MigrateUp: *migrateUp,
MigrateRollback: *migrateRollback,

View File

@@ -0,0 +1,120 @@
package handlers
import (
"context"
"net/http"
"strconv"
"git.haelnorr.com/h/golib/cookies"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/notify"
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/view/component/popup"
"github.com/coder/websocket"
"github.com/pkg/errors"
)
func NotificationWS(
s *hws.Server,
cfg *config.Config,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Upgrade") != "websocket" {
throwNotFound(s, w, r, r.URL.Path)
return
}
nc, err := setupClient(s, w, r)
if err != nil {
s.LogError(hws.HWSError{
Message: "Failed to get notification client",
Error: err,
Level: hws.ErrorERROR,
StatusCode: http.StatusInternalServerError,
})
return
}
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{cfg.HWSAuth.TrustedHost},
})
if err != nil {
s.LogError(hws.HWSError{
Message: "Failed to open websocket",
Error: err,
Level: hws.ErrorERROR,
StatusCode: http.StatusInternalServerError,
})
return
}
defer ws.CloseNow()
ctx := ws.CloseRead(r.Context())
err = notifyLoop(ctx, nc, ws)
if err != nil {
s.LogError(hws.HWSError{
Message: "Notification error",
Error: err,
Level: hws.ErrorERROR,
StatusCode: http.StatusInternalServerError,
})
}
},
)
}
func setupClient(s *hws.Server, w http.ResponseWriter, r *http.Request) (*hws.Client, error) {
user := db.CurrentUser(r.Context())
altID := ""
if user != nil {
altID = strconv.Itoa(user.ID)
}
subCookie, err := r.Cookie("ws_sub_id")
subID := ""
if err == nil {
subID = subCookie.Value
}
nc, err := s.GetClient(subID, altID)
if err != nil {
return nil, errors.Wrap(err, "s.GetClient")
}
cookies.SetCookie(w, "ws_sub_id", "/", nc.ID(), 0)
return nc, nil
}
func notifyLoop(ctx context.Context, c *hws.Client, ws *websocket.Conn) error {
notifs, stop := c.Listen()
defer close(stop)
count := 0
for {
select {
case <-ctx.Done():
return nil
case nt, ok := <-notifs:
count++
if !ok {
return nil
}
w, err := ws.Writer(ctx, websocket.MessageText)
if err != nil {
return errors.Wrap(err, "ws.Writer")
}
switch nt.Level {
case hws.LevelShutdown:
err = popup.Toast(nt, count, 30000).Render(ctx, w)
case notify.LevelWarn:
err = popup.Toast(nt, count, 10000).Render(ctx, w)
case notify.LevelError:
// do error modal
default:
err = popup.Toast(nt, count, 6000).Render(ctx, w)
}
if err != nil {
return errors.Wrap(err, "popup.Toast")
}
err = w.Close()
if err != nil {
return errors.Wrap(err, "w.Close")
}
}
}
}

View File

@@ -7,36 +7,45 @@ import (
"github.com/pkg/errors"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/notify"
)
// Handles responses to the / path. Also serves a 404 Page for paths that
// don't have explicit handlers
func Test(server *hws.Server) http.Handler {
func NotifyTester(server *hws.Server) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
page.Test().Render(r.Context(), w)
} else {
r.ParseForm()
notifytype := r.Form.Get("type")
target := r.Form.Get("target")
title := r.Form.Get("title")
level := map[string]notify.Level{
"info": notify.LevelInfo,
"success": notify.LevelSuccess,
"warn": notify.LevelWarn,
"error": notify.LevelError,
}[r.Form.Get("type")]
error := errors.New("This is a stack trace. No really i swear. Just pretend ok? Thanks")
message := r.Form.Get("message")
switch notifytype {
case "error":
err := errors.New(message)
notifyInternalServiceError(server, w, title, err)
return
case "warn":
notifyWarning(w, title, message)
return
case "info":
notifyInfo(w, title, message)
return
case "success":
notifySuccess(w, title, message)
return
nt := notify.Notification{
Target: notify.Target(target),
Title: title,
Message: message,
Level: level,
Details: error.Error(),
}
if target == "" {
server.NotifyAll(nt)
} else {
server.NotifySub(nt)
}
}
},
)
}
func notifyInfoWS() {
}

View File

@@ -0,0 +1,110 @@
package popup
import "fmt"
import "git.haelnorr.com/h/golib/notify"
import "git.haelnorr.com/h/golib/hws"
// ToastNotificationWS creates a toast notification sent via WebSocket with OOB swap
// Backend should pass: notifType ("success"|"warning"|"info"), title, message
templ Toast(nt notify.Notification, id, duration int) {
{{
// Determine classes server-side based on notifType
color := map[notify.Level]string{
notify.LevelSuccess: "green",
notify.LevelWarn: "yellow",
notify.LevelInfo: "blue",
hws.LevelShutdown: "yellow",
}
containerClass := fmt.Sprintf("bg-dark-%s border-2 border-%s", color[nt.Level], color[nt.Level])
textClass := fmt.Sprintf("text-%s", color[nt.Level])
progressClass := fmt.Sprintf("bg-%s", color[nt.Level])
}}
<div
id="toast-ws-container"
hx-swap-oob="beforeend:#toast-ws-container"
>
<div
id={ fmt.Sprintf("ws-toast-%d", id) }
class={ fmt.Sprintf("pointer-events-auto rounded-lg shadow-lg overflow-hidden w-full mb-2 %s", containerClass) }
role="alert"
x-data={ templ.JSFuncCall("progressBar", fmt.Sprintf("ws-toast-%d", id), duration).CallInline }
@mouseenter="paused = true"
>
<!-- @mouseleave="paused = false" -->
<!-- Toast Content -->
<div class="p-4 max-w-80">
<div class="flex justify-between items-start gap-3">
<!-- Icon + Content -->
<div class="flex items-start gap-3 flex-1">
<!-- Icon (rendered server-side based on notifType) -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class={ fmt.Sprintf("size-5 shrink-0 %s", textClass) }
>
switch nt.Level {
case notify.LevelSuccess:
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
clip-rule="evenodd"
></path>
case notify.LevelWarn:
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
></path>
default:
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
></path>
}
</svg>
<!-- Text Content (rendered server-side) -->
<div class="flex-1 min-w-0 max-w-48 text-wrap">
<p class={ fmt.Sprintf("font-semibold %s", textClass) }>
{ nt.Title }
</p>
<p class="text-sm text-subtext0 mt-1 text-wrap">
{ nt.Message }
</p>
</div>
</div>
<!-- Close Button -->
<button
onclick="this.closest('[id^=ws-toast-]').remove()"
class="text-subtext0 hover:text-text transition shrink-0 hover:cursor-pointer"
aria-label="Close notification"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
</div>
<!-- Progress Bar (vanilla JS manages width) -->
<div class="h-1 bg-surface0">
<div
id={ fmt.Sprintf("ws-toast-%d-progress", id) }
class={ fmt.Sprintf("h-full %s", progressClass) }
style="width: 0"
></div>
</div>
</div>
</div>
}

View File

@@ -1,10 +1,9 @@
package popup
// ToastContainer displays stacked toast notifications (success, warning, info)
// Supports both regular toasts (AlpineJS) and WebSocket toasts (HTMX OOB swaps)
templ ToastContainer() {
<div class="fixed top-20 right-5 z-40 flex flex-col gap-3 max-w-sm pointer-events-none">
<template x-for="toast in toasts" :key="toast.id">
@ToastNotification()
</template>
<div id="toast-ws-container"></div>
</div>
}

View File

@@ -1,114 +0,0 @@
package popup
// ToastNotification displays an individual toast notification
templ ToastNotification() {
<div
class="pointer-events-auto rounded-lg shadow-lg overflow-hidden w-full"
:class="{
'bg-dark-green border-2 border-green': toast.type === 'success',
'bg-dark-yellow border-2 border-yellow': toast.type === 'warning',
'bg-dark-blue border-2 border-blue': toast.type === 'info'
}"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-full"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-full"
@mouseenter="toast.paused = true"
@mouseleave="toast.paused = false"
role="alert"
>
<!-- Toast Content -->
<div class="p-4">
<div class="flex justify-between items-start gap-3">
<!-- Icon + Content -->
<div class="flex items-start gap-3 flex-1">
<!-- Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-5 flex-shrink-0"
:class="{
'text-green': toast.type === 'success',
'text-yellow': toast.type === 'warning',
'text-blue': toast.type === 'info'
}"
>
<!-- Success Icon (checkmark) -->
<template x-if="toast.type === 'success'">
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
clip-rule="evenodd"
></path>
</template>
<!-- Warning Icon (exclamation) -->
<template x-if="toast.type === 'warning'">
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
></path>
</template>
<!-- Info Icon (information) -->
<template x-if="toast.type === 'info'">
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
></path>
</template>
</svg>
<!-- Text Content -->
<div class="flex-1 min-w-0">
<p
class="font-semibold"
:class="{
'text-green': toast.type === 'success',
'text-yellow': toast.type === 'warning',
'text-blue': toast.type === 'info'
}"
x-text="toast.title"
></p>
<p class="text-sm text-subtext0 mt-1" x-text="toast.message"></p>
</div>
</div>
<!-- Close Button -->
<button
@click="removeToast(toast.id)"
class="text-subtext0 hover:text-text transition flex-shrink-0"
aria-label="Close notification"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
</div>
<!-- Progress Bar -->
<div class="h-1 bg-surface0">
<div
class="h-full"
:class="{
'bg-green': toast.type === 'success',
'bg-yellow': toast.type === 'warning',
'bg-blue': toast.type === 'info'
}"
:style="`width: ${toast.progress}%`"
></div>
</div>
</div>
}

View File

@@ -8,13 +8,11 @@ import "git.haelnorr.com/h/oslstats/pkg/contexts"
// Global page layout. Includes HTML document settings, header tags
// navbar and footer
templ Global(title string) {
{{ htmxLogging := contexts.HTMXLog(ctx) }}
{{ devInfo := contexts.DevMode(ctx) }}
<!DOCTYPE html>
<html
lang="en"
x-data="{
theme: localStorage.getItem('theme')
|| 'system'}"
x-data="{ theme: localStorage.getItem('theme') || 'system'}"
x-init="$watch('theme', (val) => localStorage.setItem('theme', val))"
x-bind:class="{'dark': theme === 'dark' || (theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches)}"
@@ -26,147 +24,23 @@ templ Global(title string) {
<title>{ title }</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico"/>
<link href="/static/css/output.css" rel="stylesheet"/>
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
<script src="https://unpkg.com/alpinejs" defer></script>
if htmxLogging {
<script src="/static/vendored/htmx@2.0.8.min.js"></script>
<script src="/static/vendored/htmx-ext-ws.min.js"></script>
<script src="/static/js/toasts.js" defer></script>
<script src="/static/vendored/alpinejs@3.15.4.min.js" defer></script>
if devInfo.HTMXLog {
<script>
htmx.logAll();
</script>
}
<script>
const bodyData = {
// Error modal (blocking, full-screen) - for 500/503 errors
errorModal: {
show: false,
code: 0,
title: '',
message: '',
details: ''
},
// Toast notifications (non-blocking, stacked) - for success/warning/info
toasts: [],
toastIdCounter: 0,
// Handle HTMX beforeSwap event - intercept both errors and successes
handleBeforeSwap(event) {
const xhr = event.detail.xhr;
if (!xhr) return;
const status = xhr.status;
const trigger = xhr.getResponseHeader('HX-Trigger');
if (!trigger) return;
try {
const data = JSON.parse(trigger);
// Handle 500/503 error modals
if ((status === 500 || status === 503) && data.showErrorModal) {
this.errorModal = {
show: true,
code: data.showErrorModal.code,
title: data.showErrorModal.title,
message: data.showErrorModal.message,
details: data.showErrorModal.details
};
// Prevent swap but allow error logging
event.detail.shouldSwap = false;
return;
}
// Handle success/warning/info toasts (200-299)
if (status >= 200 && status < 300 && data.showNotification) {
this.addToast(data.showNotification);
}
} catch (e) {
console.error('Failed to parse HX-Trigger:', e);
}
},
// Add toast to stack
addToast(notification) {
const toast = {
id: ++this.toastIdCounter,
type: notification.type,
title: notification.title,
message: notification.message,
paused: false,
progress: 0
};
this.toasts.push(toast);
// Determine timeout based on type
const timeout = notification.type === 'warning' ? 5000 : 3000;
// Start progress animation
this.animateToastProgress(toast.id, timeout);
},
// Animate toast progress bar and auto-dismiss
animateToastProgress(toastId, duration) {
const toast = this.toasts.find(t => t.id === toastId);
if (!toast) return;
const startTime = performance.now();
let totalPausedTime = 0;
let pauseStartTime = null;
const animate = (currentTime) => {
const toast = this.toasts.find(t => t.id === toastId);
if (!toast) return; // Toast was manually removed
if (toast.paused) {
// Track when pause started
if (pauseStartTime === null) {
pauseStartTime = currentTime;
}
// Keep animating while paused
requestAnimationFrame(animate);
return;
} else {
// If we were paused, accumulate the paused time
if (pauseStartTime !== null) {
totalPausedTime += currentTime - pauseStartTime;
pauseStartTime = null;
}
}
// Calculate actual elapsed time (excluding paused time)
const elapsed = currentTime - startTime - totalPausedTime;
toast.progress = Math.min((elapsed / duration) * 100, 100);
if (elapsed >= duration) {
this.removeToast(toastId);
} else {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
},
// Remove toast from stack
removeToast(id) {
this.toasts = this.toasts.filter(t => t.id !== id);
},
// Close error modal
closeErrorModal() {
this.errorModal.show = false;
}
};
</script>
</head>
<body
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden"
x-data="bodyData"
x-on:htmx:before-swap="handleBeforeSwap($event)"
x-on:keydown.escape.window="closeErrorModal()"
>
@popup.ErrorModal()
@popup.ToastContainer()
<body
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden"
hx-ext="ws"
ws-connect={ devInfo.WebsocketBase + "/ws/notifications" }
>
<!-- @popup.ErrorModal() -->
@popup.ToastContainer()
<div
id="main-content"
class="flex flex-col h-screen justify-between"

View File

@@ -24,12 +24,25 @@ templ Test() {
id="type"
class="bg-base text-text border border-overlay0 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-mauve"
>
<option value="error">Error Modal</option>
<option value="warn">Warning Toast</option>
<option value="info">Info Toast</option>
<option value="success">Success Toast</option>
<option value="info">Info Toast</option>
<option value="warn">Warning Toast</option>
<!-- <option value="error">Error Modal</option> -->
</select>
</div>
<!-- Target Input -->
<div class="flex flex-col gap-2">
<label for="target" class="text-sm font-semibold text-subtext1">
Title
</label>
<input
type="text"
name="target"
id="target"
placeholder="Enter target ID"
class="bg-base text-text border border-overlay0 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-mauve"
/>
</div>
<!-- Title Input -->
<div class="flex flex-col gap-2">
<label for="title" class="text-sm font-semibold text-subtext1">

BIN
oslstats

Binary file not shown.

View File

@@ -8,12 +8,17 @@ func (c Key) String() string {
return "oslstats context key " + string(c)
}
var HTMXLogKey Key = Key("htmxlog")
var DevModeKey Key = Key("devmode")
func HTMXLog(ctx context.Context) bool {
htmxlog, ok := ctx.Value(HTMXLogKey).(bool)
func DevMode(ctx context.Context) DevInfo {
devmode, ok := ctx.Value(DevModeKey).(DevInfo)
if !ok {
return false
return DevInfo{}
}
return htmxlog
return devmode
}
type DevInfo struct {
WebsocketBase string
HTMXLog bool
}

View File

@@ -364,6 +364,9 @@
.mr-5 {
margin-right: calc(var(--spacing) * 5);
}
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
.mb-8 {
margin-bottom: calc(var(--spacing) * 8);
}
@@ -454,6 +457,12 @@
.max-w-7xl {
max-width: var(--container-7xl);
}
.max-w-48 {
max-width: calc(var(--spacing) * 48);
}
.max-w-80 {
max-width: calc(var(--spacing) * 80);
}
.max-w-100 {
max-width: calc(var(--spacing) * 100);
}
@@ -478,6 +487,9 @@
.flex-shrink-0 {
flex-shrink: 0;
}
.shrink-0 {
flex-shrink: 0;
}
.flex-grow {
flex-grow: 1;
}
@@ -707,6 +719,9 @@
.p-4 {
padding: calc(var(--spacing) * 4);
}
.p-5 {
padding: calc(var(--spacing) * 5);
}
.p-6 {
padding: calc(var(--spacing) * 6);
}
@@ -807,6 +822,9 @@
--tw-tracking: var(--tracking-tight);
letter-spacing: var(--tracking-tight);
}
.text-wrap {
text-wrap: wrap;
}
.break-all {
word-break: break-all;
}

View File

@@ -0,0 +1,180 @@
const popupData = {
// Error modal (blocking, full-screen) - for 500/503 errors
errorModal: {
show: false,
code: 0,
title: "",
message: "",
details: "",
},
// Toast notifications (non-blocking, stacked) - for success/warning/info
toasts: [],
toastIdCounter: 0,
// Handle HTMX beforeSwap event - intercept both errors and successes
handleBeforeSwap(event) {
const xhr = event.detail.xhr;
if (!xhr) return;
const status = xhr.status;
const trigger = xhr.getResponseHeader("HX-Trigger");
if (!trigger) return;
try {
const data = JSON.parse(trigger);
// Handle 500/503 error modals
if ((status === 500 || status === 503) && data.showErrorModal) {
this.errorModal = {
show: true,
code: data.showErrorModal.code,
title: data.showErrorModal.title,
message: data.showErrorModal.message,
details: data.showErrorModal.details,
};
// Prevent swap but allow error logging
event.detail.shouldSwap = false;
return;
}
// Handle success/warning/info toasts (200-299)
if (status >= 200 && status < 300 && data.showNotification) {
this.addToast(data.showNotification);
}
} catch (e) {
console.error("Failed to parse HX-Trigger:", e);
}
},
// Add toast to stack
addToast(notification) {
const toast = {
id: ++this.toastIdCounter,
type: notification.type,
title: notification.title,
message: notification.message,
paused: false,
progress: 0,
};
this.toasts.push(toast);
// Determine timeout based on type
const timeout = notification.type === "warning" ? 5000 : 3000;
// Start progress animation
this.animateToastProgress(toast.id, timeout);
},
// Animate toast progress bar and auto-dismiss
animateToastProgress(toastId, duration) {
const toast = this.toasts.find((t) => t.id === toastId);
if (!toast) return;
const startTime = performance.now();
let totalPausedTime = 0;
let pauseStartTime = null;
const animate = (currentTime) => {
const toast = this.toasts.find((t) => t.id === toastId);
if (!toast) return; // Toast was manually removed
if (toast.paused) {
// Track when pause started
if (pauseStartTime === null) {
pauseStartTime = currentTime;
}
// Keep animating while paused
requestAnimationFrame(animate);
return;
} else {
// If we were paused, accumulate the paused time
if (pauseStartTime !== null) {
totalPausedTime += currentTime - pauseStartTime;
pauseStartTime = null;
}
}
// Calculate actual elapsed time (excluding paused time)
const elapsed = currentTime - startTime - totalPausedTime;
toast.progress = Math.min((elapsed / duration) * 100, 100);
if (elapsed >= duration) {
this.removeToast(toastId);
} else {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
},
// Remove toast from stack
removeToast(id) {
this.toasts = this.toasts.filter((t) => t.id !== id);
},
// Close error modal
closeErrorModal() {
this.errorModal.show = false;
},
};
// Global function for WebSocket toasts to animate progress and auto-dismiss
// Pure vanilla JavaScript - no Alpine.js dependency
// Called via inline script in ToastNotificationWS template
function animateToastProgress(toastId, duration) {
const toastEl = document.getElementById(toastId);
const progressEl = document.getElementById(toastId + "-progress");
if (!toastEl || !progressEl) {
console.error("animateToastProgress: elements not found", toastId);
return;
}
const startTime = performance.now();
let totalPausedTime = 0;
let pauseStartTime = null;
const animate = (currentTime) => {
const el = document.getElementById(toastId);
const progEl = document.getElementById(toastId + "-progress");
if (!el || !progEl) return; // Toast was manually removed
// Check if paused (set via onmouseenter/onmouseleave)
const isPaused = el.dataset.paused === "true";
if (isPaused) {
// Track when pause started
if (pauseStartTime === null) {
pauseStartTime = currentTime;
}
// Keep animating while paused
requestAnimationFrame(animate);
return;
} else {
// If we were paused, accumulate the paused time
if (pauseStartTime !== null) {
totalPausedTime += currentTime - pauseStartTime;
pauseStartTime = null;
}
}
// Calculate actual elapsed time (excluding paused time)
const elapsed = currentTime - startTime - totalPausedTime;
// Progress bar goes from 100% to 0%
const progress = Math.max(100 - (elapsed / duration) * 100, 0);
progEl.style.width = progress + "%";
if (elapsed >= duration) {
// Remove the toast element
el.remove();
} else {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}

View File

@@ -0,0 +1,54 @@
function progressBar(toastId, duration) {
const progressBar = {
progress: 0,
paused: false,
animateToastProgress() {
const toast = document.getElementById(toastId);
if (!toast) return;
const bar = document.getElementById([toastId, "progress"].join("-"));
const startTime = performance.now();
let totalPausedTime = 0;
let pauseStartTime = null;
const animate = (currentTime) => {
const toast = document.getElementById(toastId);
if (!toast) return; // Toast was manually removed
if (this.paused) {
// Track when pause started
if (pauseStartTime === null) {
pauseStartTime = currentTime;
}
// Keep animating while paused
requestAnimationFrame(animate);
return;
} else {
// If we were paused, accumulate the paused time
if (pauseStartTime !== null) {
totalPausedTime += currentTime - pauseStartTime;
pauseStartTime = null;
}
}
// Calculate actual elapsed time (excluding paused time)
const elapsed = currentTime - startTime - totalPausedTime;
this.progress = Math.min((elapsed / duration) * 100, 100) + "%";
bar.style["width"] = this.progress;
if (elapsed >= duration) {
toast.remove()
} else {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
};
progressBar.animateToastProgress();
return progressBar;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long