we now got websockets baby

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

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">