From 7504e718d717e9d8d6fc524c2bb9b371f67fe3dd Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 5 Feb 2026 14:00:51 +0100 Subject: [PATCH] Add better error page --- proxy/internal/accesslog/middleware.go | 7 ++- proxy/internal/proxy/context.go | 8 +++ proxy/internal/proxy/reverseproxy.go | 38 ++++++++---- proxy/web/dist/index.html | 4 +- proxy/web/public/robots.txt | 2 + proxy/web/src/ErrorPage.tsx | 66 +++++++++++++++++++++ proxy/web/src/components/ConnectionLine.tsx | 26 ++++++++ proxy/web/src/components/StatusCard.tsx | 38 ++++++++++++ proxy/web/src/data.ts | 28 ++++++++- proxy/web/src/main.tsx | 2 +- proxy/web/src/pages/ErrorPage.tsx | 42 ------------- proxy/web/web.go | 19 ++++-- 12 files changed, 217 insertions(+), 63 deletions(-) create mode 100644 proxy/web/public/robots.txt create mode 100644 proxy/web/src/ErrorPage.tsx create mode 100644 proxy/web/src/components/ConnectionLine.tsx create mode 100644 proxy/web/src/components/StatusCard.tsx delete mode 100644 proxy/web/src/pages/ErrorPage.tsx diff --git a/proxy/internal/accesslog/middleware.go b/proxy/internal/accesslog/middleware.go index 706de7d3c..b5d1c0e9d 100644 --- a/proxy/internal/accesslog/middleware.go +++ b/proxy/internal/accesslog/middleware.go @@ -24,10 +24,13 @@ func (l *Logger) Middleware(next http.Handler) http.Handler { // headers that we wish to use to gather that information on the request. sourceIp := extractSourceIP(r) + // Generate request ID early so it can be used by error pages. + requestID := xid.New().String() + // Create a mutable struct to capture data from downstream handlers. // We pass a pointer in the context - the pointer itself flows down immutably, // but the struct it points to can be mutated by inner handlers. - capturedData := &proxy.CapturedData{} + capturedData := &proxy.CapturedData{RequestID: requestID} ctx := proxy.WithCapturedData(r.Context(), capturedData) start := time.Now() @@ -41,7 +44,7 @@ func (l *Logger) Middleware(next http.Handler) http.Handler { } entry := logEntry{ - ID: xid.New().String(), + ID: requestID, ServiceId: capturedData.GetServiceId(), AccountID: string(capturedData.GetAccountId()), Host: host, diff --git a/proxy/internal/proxy/context.go b/proxy/internal/proxy/context.go index 36da03d30..4131a4ae6 100644 --- a/proxy/internal/proxy/context.go +++ b/proxy/internal/proxy/context.go @@ -19,10 +19,18 @@ const ( // to pass data back up the middleware chain. type CapturedData struct { mu sync.RWMutex + RequestID string ServiceId string AccountId types.AccountID } +// GetRequestID safely gets the request ID +func (c *CapturedData) GetRequestID() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.RequestID +} + // SetServiceId safely sets the service ID func (c *CapturedData) SetServiceId(serviceId string) { c.mu.Lock() diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index d64073941..da692cd16 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -41,8 +41,10 @@ func NewReverseProxy(transport http.RoundTripper, logger *log.Logger) *ReversePr func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { target, serviceId, accountID, exists := p.findTargetForRequest(r) if !exists { + requestID := getRequestID(r) web.ServeErrorPage(w, r, http.StatusNotFound, "Service Not Found", - "The requested service could not be found. Please check the URL, try refreshing, or check if the peer is running. If that doesn't work, see our documentation for help.") + "The requested service could not be found. Please check the URL, try refreshing, or check if the peer is running. If that doesn't work, see our documentation for help.", + requestID, web.ErrorStatus{Proxy: true, Peer: false, Destination: false}) return } @@ -71,37 +73,51 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { // proxyErrorHandler handles errors from the reverse proxy and serves // user-friendly error pages instead of raw error responses. func proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) { - title, message, code := classifyProxyError(err) - web.ServeErrorPage(w, r, code, title, message) + requestID := getRequestID(r) + title, message, code, status := classifyProxyError(err) + web.ServeErrorPage(w, r, code, title, message, requestID, status) } -// classifyProxyError determines the appropriate error title, message, and HTTP -// status code based on the error type. -func classifyProxyError(err error) (title, message string, code int) { +// getRequestID retrieves the request ID from context or returns empty string. +func getRequestID(r *http.Request) string { + if capturedData := CapturedDataFromContext(r.Context()); capturedData != nil { + return capturedData.GetRequestID() + } + return "" +} + +// classifyProxyError determines the appropriate error title, message, HTTP +// status code, and component status based on the error type. +func classifyProxyError(err error) (title, message string, code int, status web.ErrorStatus) { switch { case errors.Is(err, context.DeadlineExceeded): return "Request Timeout", "The request timed out while trying to reach the service. Please refresh the page and try again.", - http.StatusGatewayTimeout + http.StatusGatewayTimeout, + web.ErrorStatus{Proxy: true, Peer: true, Destination: false} case errors.Is(err, context.Canceled): return "Request Canceled", "The request was canceled before it could be completed. Please refresh the page and try again.", - http.StatusBadGateway + http.StatusBadGateway, + web.ErrorStatus{Proxy: true, Peer: true, Destination: false} case errors.Is(err, roundtrip.ErrNoAccountID): return "Configuration Error", "The request could not be processed due to a configuration issue. Please refresh the page and try again.", - http.StatusInternalServerError + http.StatusInternalServerError, + web.ErrorStatus{Proxy: false, Peer: false, Destination: false} case strings.Contains(err.Error(), "connection refused"): return "Service Unavailable", "The connection to the service was refused. Please verify that the service is running and try again.", - http.StatusBadGateway + http.StatusBadGateway, + web.ErrorStatus{Proxy: true, Peer: true, Destination: false} default: return "Peer Not Connected", "The connection to the peer could not be established. Please ensure the peer is running and connected to the NetBird network.", - http.StatusBadGateway + http.StatusBadGateway, + web.ErrorStatus{Proxy: true, Peer: false, Destination: false} } } diff --git a/proxy/web/dist/index.html b/proxy/web/dist/index.html index a57ba7ea5..e69a2787e 100644 --- a/proxy/web/dist/index.html +++ b/proxy/web/dist/index.html @@ -6,8 +6,8 @@ NetBird Service - - + + diff --git a/proxy/web/public/robots.txt b/proxy/web/public/robots.txt new file mode 100644 index 000000000..1f53798bb --- /dev/null +++ b/proxy/web/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/proxy/web/src/ErrorPage.tsx b/proxy/web/src/ErrorPage.tsx new file mode 100644 index 000000000..c335e2ae1 --- /dev/null +++ b/proxy/web/src/ErrorPage.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from "react"; +import {BookText, RotateCw, Server, Globe, UserIcon, WaypointsIcon} from "lucide-react"; +import { Title } from "@/components/Title"; +import { Description } from "@/components/Description"; +import Button from "@/components/Button"; +import { PoweredByNetBird } from "@/components/PoweredByNetBird"; +import { StatusCard } from "@/components/StatusCard"; +import type { ErrorData } from "@/data"; + +export function ErrorPage({ code, title, message, proxy = true, peer = true, destination = true, requestId }: ErrorData) { + useEffect(() => { + document.title = `${title} - NetBird Service`; + }, [title]); + + const [timestamp] = useState(() => new Date().toISOString()); + + return ( +
+ {/* Error Code */} +
+ {code} {title === "Service Unavailable" ? "Bad Gateway" : title.split(" ").slice(0, 2).join(" ")} +
+ + {/* Title */} + {title} + + {/* Description */} + {message} + + {/* Status Cards */} +
+ + + + +
+ + {/* Buttons */} +
+ + +
+ + {/* Request Info */} +
+
+ REQUEST-ID: {requestId} +
+
+ TIMESTAMP: {timestamp} +
+
+ + +
+ ); +} diff --git a/proxy/web/src/components/ConnectionLine.tsx b/proxy/web/src/components/ConnectionLine.tsx new file mode 100644 index 000000000..fbb0bcd1e --- /dev/null +++ b/proxy/web/src/components/ConnectionLine.tsx @@ -0,0 +1,26 @@ +import { X } from "lucide-react"; + +interface ConnectionLineProps { + success?: boolean; +} + +export function ConnectionLine({ success = true }: ConnectionLineProps) { + if (success) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+
+ +
+
+
+ ); +} diff --git a/proxy/web/src/components/StatusCard.tsx b/proxy/web/src/components/StatusCard.tsx new file mode 100644 index 000000000..40feebd8d --- /dev/null +++ b/proxy/web/src/components/StatusCard.tsx @@ -0,0 +1,38 @@ +import type { LucideIcon } from "lucide-react"; +import { ConnectionLine } from "./ConnectionLine"; + +interface StatusCardProps { + icon: LucideIcon; + label: string; + detail?: string; + success?: boolean; + line?: boolean; +} + +export function StatusCard({ + icon: Icon, + label, + detail, + success = true, + line = true, +}: StatusCardProps) { + return ( + <> + {line && } +
+
+ +
+ {label} + + {success ? "Connected" : "Unreachable"} + + {detail && ( + + {detail} + + )} +
+ + ); +} diff --git a/proxy/web/src/data.ts b/proxy/web/src/data.ts index 7b03e731f..38adf86b4 100644 --- a/proxy/web/src/data.ts +++ b/proxy/web/src/data.ts @@ -9,6 +9,10 @@ export interface ErrorData { code: number title: string message: string + proxy?: boolean + peer?: boolean + destination?: boolean + requestId?: string } // Data injected by Go templates @@ -25,5 +29,27 @@ declare global { } export function getData(): Data { - return window.__DATA__ ?? {} + const data = window.__DATA__ ?? {} + + // Dev mode: allow ?page=error query param to preview error page + if (import.meta.env.DEV) { + const params = new URLSearchParams(window.location.search) + const page = params.get('page') + if (page === 'error') { + return { + ...data, + page: 'error', + error: data.error ?? { + code: 503, + title: 'Service Unavailable', + message: 'The service you are trying to access is temporarily unavailable. Please try again later.', + proxy: true, + peer: false, + destination: false, + }, + } + } + } + + return data } diff --git a/proxy/web/src/main.tsx b/proxy/web/src/main.tsx index 328b31140..e836cc12b 100644 --- a/proxy/web/src/main.tsx +++ b/proxy/web/src/main.tsx @@ -2,7 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' -import { ErrorPage } from './pages/ErrorPage.tsx' +import { ErrorPage } from './ErrorPage.tsx' import { getData } from '@/data' const data = getData() diff --git a/proxy/web/src/pages/ErrorPage.tsx b/proxy/web/src/pages/ErrorPage.tsx deleted file mode 100644 index a52abe1dd..000000000 --- a/proxy/web/src/pages/ErrorPage.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useEffect } from "react"; -import { BookText, RotateCw } from "lucide-react"; -import { Title } from "@/components/Title"; -import { Description } from "@/components/Description"; -import { PoweredByNetBird } from "@/components/PoweredByNetBird"; -import { Card } from "@/components/Card"; -import Button from "@/components/Button"; -import type { ErrorData } from "@/data"; - -export function ErrorPage({ code, title, message }: ErrorData) { - useEffect(() => { - document.title = `${title} - NetBird Service`; - }, [title]); - - return ( -
- -
{code}
- {title} - {message} -
- - -
-
- - -
- ); -} diff --git a/proxy/web/web.go b/proxy/web/web.go index 4959369d1..eca179688 100644 --- a/proxy/web/web.go +++ b/proxy/web/web.go @@ -108,14 +108,25 @@ func ServeHTTP(w http.ResponseWriter, r *http.Request, data any, statusCode ...i w.Write(buf.Bytes()) } +// ErrorStatus represents the connection status for each component in the error page. +type ErrorStatus struct { + Proxy bool + Peer bool + Destination bool +} + // ServeErrorPage renders a user-friendly error page with the given details. -func ServeErrorPage(w http.ResponseWriter, r *http.Request, code int, title, message string) { +func ServeErrorPage(w http.ResponseWriter, r *http.Request, code int, title, message, requestID string, status ErrorStatus) { ServeHTTP(w, r, map[string]any{ "page": "error", "error": map[string]any{ - "code": code, - "title": title, - "message": message, + "code": code, + "title": title, + "message": message, + "proxy": status.Proxy, + "peer": status.Peer, + "destination": status.Destination, + "requestId": requestID, }, }, code) }