From ad6b00e722cd86a3a13c07e00dfacc9efadde809 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Mon, 26 Jan 2026 12:39:37 +1100 Subject: [PATCH] error modals are so on --- .gitignore | 1 + cmd/oslstats/httpserver.go | 3 +- internal/handlers/errorpage.go | 24 +-- internal/handlers/errors.go | 48 +++++ internal/handlers/notifications.go | 141 -------------- internal/handlers/notifswebsocket.go | 4 +- internal/handlers/test.go | 23 ++- .../view/component/popup/errorModal.templ | 126 ------------ .../component/popup/errorModalContainer.templ | 6 + .../view/component/popup/errorModalWS.templ | 65 +++++++ .../view/component/popup/errorPopup.templ | 63 ------ internal/view/component/popup/toast.templ | 42 ++-- .../view/component/popup/toastContainer.templ | 1 + internal/view/layout/global.templ | 3 +- internal/view/page/error.templ | 21 +- internal/view/page/test.templ | 7 +- pkg/embedfs/files/css/output.css | 25 +-- pkg/embedfs/files/js/copytoclipboard.js | 17 ++ pkg/embedfs/files/js/popups.js | 180 ------------------ 19 files changed, 195 insertions(+), 605 deletions(-) delete mode 100644 internal/handlers/notifications.go delete mode 100644 internal/view/component/popup/errorModal.templ create mode 100644 internal/view/component/popup/errorModalContainer.templ create mode 100644 internal/view/component/popup/errorModalWS.templ delete mode 100644 internal/view/component/popup/errorPopup.templ create mode 100644 pkg/embedfs/files/js/copytoclipboard.js delete mode 100644 pkg/embedfs/files/js/popups.js diff --git a/.gitignore b/.gitignore index 72d2520..71d4cb0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ tmp/ static/css/output.css internal/view/**/*_templ.go internal/view/**/*_templ.txt +oslstats # Database backups (compressed) backups/*.sql.gz diff --git a/cmd/oslstats/httpserver.go b/cmd/oslstats/httpserver.go index b01c16e..e37789e 100644 --- a/cmd/oslstats/httpserver.go +++ b/cmd/oslstats/httpserver.go @@ -35,7 +35,8 @@ func setupHttpServer( ignoredPaths := []string{ "/static/css/output.css", "/static/favicon.ico", - "/static/js/popups.js", + "/static/js/toasts.js", + "/static/js/copytoclipboard.js", "/static/js/theme.js", "/static/vendored/htmx@2.0.8.min.js", "/static/vendored/htmx-ext-ws.min.js", diff --git a/internal/handlers/errorpage.go b/internal/handlers/errorpage.go index d6e4a25..53cb172 100644 --- a/internal/handlers/errorpage.go +++ b/internal/handlers/errorpage.go @@ -7,25 +7,6 @@ import ( "git.haelnorr.com/h/oslstats/internal/view/page" ) -// func ErrorPage( -// error hws.HWSError, -// ) (hws.ErrorPage, error) { -// messages := map[int]string{ -// 400: "The request you made was malformed or unexpected.", -// 401: "You need to login to view this page.", -// 403: "You do not have permission to view this page.", -// 404: "The page or resource you have requested does not exist.", -// 500: `An error occured on the server. Please try again, and if this -// continues to happen contact an administrator.`, -// 503: "The server is currently down for maintenance and should be back soon. =)", -// } -// msg, exists := messages[error.StatusCode] -// if !exists { -// return nil, errors.New("No valid message for the given code") -// } -// return page.Error(error.StatusCode, http.StatusText(error.StatusCode), msg), nil -// } - func ErrorPage(hwsError hws.HWSError) (hws.ErrorPage, error) { // Determine if this status code should show technical details showDetails := shouldShowDetails(hwsError.StatusCode) @@ -40,7 +21,7 @@ func ErrorPage(hwsError hws.HWSError) (hws.ErrorPage, error) { // Get technical details if applicable var details string if showDetails && hwsError.Error != nil { - details = hwsError.Error.Error() + details = formatErrorDetails(hwsError.Error) } // Render appropriate template @@ -63,7 +44,7 @@ func ErrorPage(hwsError hws.HWSError) (hws.ErrorPage, error) { // shouldShowDetails determines if a status code should display technical details func shouldShowDetails(statusCode int) bool { switch statusCode { - case 400, 500, 503: // Bad Request, Internal Server Error, Service Unavailable + case 400, 418, 500, 503: // Bad Request, Internal Server Error, Service Unavailable return true case 401, 403, 404: // Unauthorized, Forbidden, Not Found return false @@ -80,6 +61,7 @@ func getDefaultMessage(statusCode int) string { 401: "You need to login to view this page.", 403: "You do not have permission to view this page.", 404: "The page or resource you have requested does not exist.", + 418: "I'm a teapot!", 500: `An error occurred on the server. Please try again, and if this continues to happen contact an administrator.`, 503: "The server is currently down for maintenance and should be back soon. =)", diff --git a/internal/handlers/errors.go b/internal/handlers/errors.go index 4e26611..bad79d6 100644 --- a/internal/handlers/errors.go +++ b/internal/handlers/errors.go @@ -1,6 +1,7 @@ package handlers import ( + "encoding/json" "fmt" "net/http" @@ -107,3 +108,50 @@ func throwNotFound( err := errors.New("Resource not found") throwError(s, w, r, http.StatusNotFound, msg, err, "debug") } + +// ErrorDetails contains structured error information for WebSocket error modals +type ErrorDetails struct { + Code int `json:"code"` + Stacktrace string `json:"stacktrace"` +} + +// formatErrorDetails extracts and formats error details from wrapped errors +func formatErrorDetails(err error) string { + if err == nil { + return "" + } + // Use %+v format to get stack trace from github.com/pkg/errors + return fmt.Sprintf("%+v", err) +} + +// SerializeErrorDetails creates a JSON string with code and stacktrace +// This is exported so it can be used when creating error notifications +func SerializeErrorDetails(code int, err error) string { + details := ErrorDetails{ + Code: code, + Stacktrace: formatErrorDetails(err), + } + jsonData, jsonErr := json.Marshal(details) + if jsonErr != nil { + // Fallback if JSON encoding fails + return fmt.Sprintf(`{"code":%d,"stacktrace":"Failed to serialize error"}`, code) + } + return string(jsonData) +} + +// parseErrorDetails extracts code and stacktrace from JSON Details field +// Returns (code, stacktrace). If parsing fails, returns (500, original details string) +func parseErrorDetails(details string) (int, string) { + if details == "" { + return 500, "" + } + + var errDetails ErrorDetails + err := json.Unmarshal([]byte(details), &errDetails) + if err != nil { + // Not JSON or malformed - treat as plain stacktrace with default code + return 500, details + } + + return errDetails.Code, errDetails.Stacktrace +} diff --git a/internal/handlers/notifications.go b/internal/handlers/notifications.go deleted file mode 100644 index 3d17197..0000000 --- a/internal/handlers/notifications.go +++ /dev/null @@ -1,141 +0,0 @@ -package handlers - -import ( - "encoding/json" - "fmt" - "net/http" - - "git.haelnorr.com/h/golib/hws" -) - -// NotificationType defines the type of notification -type NotificationType string - -const ( - NotificationSuccess NotificationType = "success" - NotificationWarning NotificationType = "warning" - NotificationInfo NotificationType = "info" -) - -// Notification represents a toast notification (success, warning, info) -type Notification struct { - Type NotificationType `json:"type"` - Title string `json:"title"` - Message string `json:"message"` -} - -// ErrorModal represents a full-screen error modal (500, 503) -type ErrorModal struct { - Code int `json:"code"` - Title string `json:"title"` - Message string `json:"message"` - Details string `json:"details"` -} - -// setHXTrigger sets the HX-Trigger header with JSON-encoded data -func setHXTrigger(w http.ResponseWriter, event string, data any) { - payload := map[string]any{ - event: data, - } - - jsonData, err := json.Marshal(payload) - if err != nil { - // Fallback if JSON encoding fails - w.Header().Set("HX-Trigger", event) - return - } - - w.Header().Set("HX-Trigger", string(jsonData)) -} - -// formatErrorDetails extracts and formats error details from wrapped errors -func formatErrorDetails(err error) string { - if err == nil { - return "" - } - - // Use %+v format to get stack trace from github.com/pkg/errors - return fmt.Sprintf("%+v", err) -} - -// notifyToast sends a toast notification via HX-Trigger header -func notifyToast( - w http.ResponseWriter, - notifType NotificationType, - title string, - message string, -) { - notification := Notification{ - Type: notifType, - Title: title, - Message: message, - } - - setHXTrigger(w, "showNotification", notification) -} - -// notifyErrorModal sends a full-screen error modal via HX-Trigger header -func notifyErrorModal( - s *hws.Server, - w http.ResponseWriter, - statusCode int, - title string, - message string, - err error, -) { - modal := ErrorModal{ - Code: statusCode, - Title: title, - Message: message, - Details: formatErrorDetails(err), - } - - // Log the error - s.LogError(hws.HWSError{ - StatusCode: statusCode, - Message: message, - Error: err, - Level: hws.ErrorERROR, - }) - - // Set response status - w.WriteHeader(statusCode) - - // Send notification via HX-Trigger - setHXTrigger(w, "showErrorModal", modal) -} - -// notifySuccess sends a success toast notification -func notifySuccess(w http.ResponseWriter, title string, message string) { - notifyToast(w, NotificationSuccess, title, message) -} - -// notifyWarning sends a warning toast notification -func notifyWarning(w http.ResponseWriter, title string, message string) { - notifyToast(w, NotificationWarning, title, message) -} - -// notifyInfo sends an info toast notification -func notifyInfo(w http.ResponseWriter, title string, message string) { - notifyToast(w, NotificationInfo, title, message) -} - -// notifyInternalServiceError sends a 500 error modal -func notifyInternalServiceError( - s *hws.Server, - w http.ResponseWriter, - message string, - err error, -) { - notifyErrorModal(s, w, 500, "Internal Server Error", message, err) -} - -// notifyServiceUnavailable sends a 503 error modal -func notifyServiceUnavailable( - s *hws.Server, - w http.ResponseWriter, - message string, - err error, -) { - notifyErrorModal(s, w, 503, "Service Unavailable", message, err) -} diff --git a/internal/handlers/notifswebsocket.go b/internal/handlers/notifswebsocket.go index 694fde0..9e5caea 100644 --- a/internal/handlers/notifswebsocket.go +++ b/internal/handlers/notifswebsocket.go @@ -104,7 +104,9 @@ func notifyLoop(ctx context.Context, c *hws.Client, ws *websocket.Conn) error { case notify.LevelWarn: err = popup.Toast(nt, count, 10000).Render(ctx, w) case notify.LevelError: - // do error modal + // Parse error code and stacktrace from Details field + code, stacktrace := parseErrorDetails(nt.Details) + err = popup.ErrorModalWS(code, stacktrace, nt, count).Render(ctx, w) default: err = popup.Toast(nt, count, 6000).Render(ctx, w) } diff --git a/internal/handlers/test.go b/internal/handlers/test.go index f9091e9..d26c094 100644 --- a/internal/handlers/test.go +++ b/internal/handlers/test.go @@ -3,11 +3,11 @@ package handlers import ( "net/http" - "git.haelnorr.com/h/oslstats/internal/view/page" "github.com/pkg/errors" "git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/notify" + "git.haelnorr.com/h/oslstats/internal/view/page" ) // Handles responses to the / path. Also serves a 404 Page for paths that @@ -15,7 +15,14 @@ import ( func NotifyTester(server *hws.Server) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { + testErr := errors.New("This is a stack trace. No really i swear. Just pretend ok? Thanks") if r.Method == "GET" { + // page, _ := ErrorPage(hws.HWSError{ + // StatusCode: http.StatusTeapot, + // Message: "This error has been rendered as a test", + // Error: testErr, + // }) + // page.Render(r.Context(), w) page.Test().Render(r.Context(), w) } else { r.ParseForm() @@ -27,16 +34,20 @@ func NotifyTester(server *hws.Server) http.Handler { "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") nt := notify.Notification{ Target: notify.Target(target), Title: title, Message: message, Level: level, - Details: error.Error(), } - if target == "" { + + // For error level, serialize error details with code + if level == notify.LevelError { + nt.Details = SerializeErrorDetails(500, testErr) + } + + if target == "all" { server.NotifyAll(nt) } else { server.NotifySub(nt) @@ -45,7 +56,3 @@ func NotifyTester(server *hws.Server) http.Handler { }, ) } - -func notifyInfoWS() { - -} diff --git a/internal/view/component/popup/errorModal.templ b/internal/view/component/popup/errorModal.templ deleted file mode 100644 index 5d67e49..0000000 --- a/internal/view/component/popup/errorModal.templ +++ /dev/null @@ -1,126 +0,0 @@ -package popup - -// ErrorModal displays a full-screen modal for critical errors (500, 503) -templ ErrorModal() { -
- -
- - -
- - -} diff --git a/internal/view/component/popup/errorModalContainer.templ b/internal/view/component/popup/errorModalContainer.templ new file mode 100644 index 0000000..b5089c6 --- /dev/null +++ b/internal/view/component/popup/errorModalContainer.templ @@ -0,0 +1,6 @@ +package popup + +// ErrorModalContainer provides the target div for WebSocket error modal OOB swaps +templ ErrorModalContainer() { +
+} \ No newline at end of file diff --git a/internal/view/component/popup/errorModalWS.templ b/internal/view/component/popup/errorModalWS.templ new file mode 100644 index 0000000..ce24fc9 --- /dev/null +++ b/internal/view/component/popup/errorModalWS.templ @@ -0,0 +1,65 @@ +package popup + +import "strconv" +import "fmt" +import "git.haelnorr.com/h/golib/notify" + +// ErrorModalWS displays a WebSocket-triggered error modal +// Matches design of error page (page.ErrorWithDetails) +templ ErrorModalWS(code int, stacktrace string, nt notify.Notification, id int) { +
+ +
+ +
+ +

{ strconv.Itoa(code) }

+ +

+ { nt.Title } +

+ +

{ nt.Message }

+ + if stacktrace != "" { +
+
+ + Details + (click to expand) + +
+
{ stacktrace }
+
+ +
+
+ } + +
+ +
+
+
+ +
+} + diff --git a/internal/view/component/popup/errorPopup.templ b/internal/view/component/popup/errorPopup.templ deleted file mode 100644 index 6f30390..0000000 --- a/internal/view/component/popup/errorPopup.templ +++ /dev/null @@ -1,63 +0,0 @@ -package popup - -templ ErrorPopup() { -
- -
-} diff --git a/internal/view/component/popup/toast.templ b/internal/view/component/popup/toast.templ index 236f47c..a503f53 100644 --- a/internal/view/component/popup/toast.templ +++ b/internal/view/component/popup/toast.templ @@ -8,16 +8,22 @@ import "git.haelnorr.com/h/golib/hws" // 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", + type style struct { + bg string + fg string + border string + bgdark string + } + // Determine classes server-side based on notifType + var colors style + switch nt.Level { + case notify.LevelSuccess: + colors = style{bg: "bg-green", fg: "text-green", border: "border-green", bgdark: "bg-dark-green"} + case notify.LevelWarn, hws.LevelShutdown: + colors = style{bg: "bg-yellow", fg: "text-yellow", border: "border-yellow", bgdark: "bg-dark-yellow"} + case notify.LevelInfo: + colors = style{bg: "bg-blue", fg: "text-blue", border: "border-blue", bgdark: "bg-dark-blue"} } - 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]) }}