mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-18 16:26:38 +00:00
Add better error page
This commit is contained in:
@@ -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.
|
// headers that we wish to use to gather that information on the request.
|
||||||
sourceIp := extractSourceIP(r)
|
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.
|
// Create a mutable struct to capture data from downstream handlers.
|
||||||
// We pass a pointer in the context - the pointer itself flows down immutably,
|
// 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.
|
// 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)
|
ctx := proxy.WithCapturedData(r.Context(), capturedData)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -41,7 +44,7 @@ func (l *Logger) Middleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry := logEntry{
|
entry := logEntry{
|
||||||
ID: xid.New().String(),
|
ID: requestID,
|
||||||
ServiceId: capturedData.GetServiceId(),
|
ServiceId: capturedData.GetServiceId(),
|
||||||
AccountID: string(capturedData.GetAccountId()),
|
AccountID: string(capturedData.GetAccountId()),
|
||||||
Host: host,
|
Host: host,
|
||||||
|
|||||||
@@ -19,10 +19,18 @@ const (
|
|||||||
// to pass data back up the middleware chain.
|
// to pass data back up the middleware chain.
|
||||||
type CapturedData struct {
|
type CapturedData struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
RequestID string
|
||||||
ServiceId string
|
ServiceId string
|
||||||
AccountId types.AccountID
|
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
|
// SetServiceId safely sets the service ID
|
||||||
func (c *CapturedData) SetServiceId(serviceId string) {
|
func (c *CapturedData) SetServiceId(serviceId string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ func NewReverseProxy(transport http.RoundTripper, logger *log.Logger) *ReversePr
|
|||||||
func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
target, serviceId, accountID, exists := p.findTargetForRequest(r)
|
target, serviceId, accountID, exists := p.findTargetForRequest(r)
|
||||||
if !exists {
|
if !exists {
|
||||||
|
requestID := getRequestID(r)
|
||||||
web.ServeErrorPage(w, r, http.StatusNotFound, "Service Not Found",
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,37 +73,51 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// proxyErrorHandler handles errors from the reverse proxy and serves
|
// proxyErrorHandler handles errors from the reverse proxy and serves
|
||||||
// user-friendly error pages instead of raw error responses.
|
// user-friendly error pages instead of raw error responses.
|
||||||
func proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
func proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
title, message, code := classifyProxyError(err)
|
requestID := getRequestID(r)
|
||||||
web.ServeErrorPage(w, r, code, title, message)
|
title, message, code, status := classifyProxyError(err)
|
||||||
|
web.ServeErrorPage(w, r, code, title, message, requestID, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// classifyProxyError determines the appropriate error title, message, and HTTP
|
// getRequestID retrieves the request ID from context or returns empty string.
|
||||||
// status code based on the error type.
|
func getRequestID(r *http.Request) string {
|
||||||
func classifyProxyError(err error) (title, message string, code int) {
|
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 {
|
switch {
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
return "Request Timeout",
|
return "Request Timeout",
|
||||||
"The request timed out while trying to reach the service. Please refresh the page and try again.",
|
"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):
|
case errors.Is(err, context.Canceled):
|
||||||
return "Request Canceled",
|
return "Request Canceled",
|
||||||
"The request was canceled before it could be completed. Please refresh the page and try again.",
|
"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):
|
case errors.Is(err, roundtrip.ErrNoAccountID):
|
||||||
return "Configuration Error",
|
return "Configuration Error",
|
||||||
"The request could not be processed due to a configuration issue. Please refresh the page and try again.",
|
"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"):
|
case strings.Contains(err.Error(), "connection refused"):
|
||||||
return "Service Unavailable",
|
return "Service Unavailable",
|
||||||
"The connection to the service was refused. Please verify that the service is running and try again.",
|
"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:
|
default:
|
||||||
return "Peer Not Connected",
|
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.",
|
"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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
proxy/web/dist/index.html
vendored
4
proxy/web/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>NetBird Service</title>
|
<title>NetBird Service</title>
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<script type="module" crossorigin src="/assets/index-ClfM9m3s.js"></script>
|
<script type="module" crossorigin src="/assets/index-BWSM6sR5.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/style-B1NSEbha.css">
|
<link rel="stylesheet" crossorigin href="/assets/style-IH-yd16d.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Go template variables injected here -->
|
<!-- Go template variables injected here -->
|
||||||
|
|||||||
2
proxy/web/public/robots.txt
Normal file
2
proxy/web/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
66
proxy/web/src/ErrorPage.tsx
Normal file
66
proxy/web/src/ErrorPage.tsx
Normal file
@@ -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 (
|
||||||
|
<main className="flex flex-col items-center mt-24 px-4 max-w-3xl mx-auto">
|
||||||
|
{/* Error Code */}
|
||||||
|
<div className="text-sm text-netbird font-normal font-mono mb-3 z-10 relative">
|
||||||
|
{code} {title === "Service Unavailable" ? "Bad Gateway" : title.split(" ").slice(0, 2).join(" ")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Title className="text-3xl!">{title}</Title>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Description className="mt-2 mb-8 max-w-md">{message}</Description>
|
||||||
|
|
||||||
|
{/* Status Cards */}
|
||||||
|
<div className="hidden sm:flex items-start justify-center w-full mt-6 mb-16 z-10 relative">
|
||||||
|
<StatusCard icon={UserIcon} label="You" line={false} />
|
||||||
|
<StatusCard icon={WaypointsIcon} label="Proxy" success={proxy} />
|
||||||
|
<StatusCard icon={Server} label="Routing Peer" success={peer} />
|
||||||
|
<StatusCard icon={Globe} label="Destination" success={destination} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex gap-3 justify-center items-center mb-6 z-10 relative">
|
||||||
|
<Button variant="primary" onClick={() => window.location.reload()}>
|
||||||
|
<RotateCw size={16} />
|
||||||
|
Refresh Page
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => window.open("https://docs.netbird.io", "_blank")}
|
||||||
|
>
|
||||||
|
<BookText size={16} />
|
||||||
|
Documentation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Request Info */}
|
||||||
|
<div className="text-center text-xs text-nb-gray-300 uppercase z-10 relative font-mono flex flex-col sm:flex-row gap-2 sm:gap-10 mt-4 mb-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-nb-gray-400">REQUEST-ID:</span> {requestId}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-nb-gray-400">TIMESTAMP:</span> {timestamp}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PoweredByNetBird />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
proxy/web/src/components/ConnectionLine.tsx
Normal file
26
proxy/web/src/components/ConnectionLine.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
interface ConnectionLineProps {
|
||||||
|
success?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectionLine({ success = true }: ConnectionLineProps) {
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center h-12 w-full px-5">
|
||||||
|
<div className="w-full border-t-2 border-dashed border-green-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center h-12 min-w-10 px-5 relative">
|
||||||
|
<div className="w-full border-t-2 border-dashed border-nb-gray-900" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 rounded-full flex items-center justify-center">
|
||||||
|
<X size={18} className="text-netbird" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
proxy/web/src/components/StatusCard.tsx
Normal file
38
proxy/web/src/components/StatusCard.tsx
Normal file
@@ -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 && <ConnectionLine success={success} />}
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-14 h-14 rounded-md flex items-center justify-center from-nb-gray-940 to-nb-gray-930/70 bg-gradient-to-br border border-nb-gray-910">
|
||||||
|
<Icon size={20} className="text-nb-gray-200" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-nb-gray-200 font-normal mt-1">{label}</span>
|
||||||
|
<span className={`text-xs font-medium uppercase ${success ? "text-green-500" : "text-netbird"}`}>
|
||||||
|
{success ? "Connected" : "Unreachable"}
|
||||||
|
</span>
|
||||||
|
{detail && (
|
||||||
|
<span className="text-xs text-nb-gray-400 truncate text-center">
|
||||||
|
{detail}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ export interface ErrorData {
|
|||||||
code: number
|
code: number
|
||||||
title: string
|
title: string
|
||||||
message: string
|
message: string
|
||||||
|
proxy?: boolean
|
||||||
|
peer?: boolean
|
||||||
|
destination?: boolean
|
||||||
|
requestId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data injected by Go templates
|
// Data injected by Go templates
|
||||||
@@ -25,5 +29,27 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getData(): Data {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { ErrorPage } from './pages/ErrorPage.tsx'
|
import { ErrorPage } from './ErrorPage.tsx'
|
||||||
import { getData } from '@/data'
|
import { getData } from '@/data'
|
||||||
|
|
||||||
const data = getData()
|
const data = getData()
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<main className="flex flex-col items-center mt-40 px-4 max-w-xl mx-auto">
|
|
||||||
<Card className="text-center">
|
|
||||||
<div className="text-5xl font-bold text-nb-gray-200 mb-4">{code}</div>
|
|
||||||
<Title>{title}</Title>
|
|
||||||
<Description className="mt-2">{message}</Description>
|
|
||||||
<div className="mt-6 flex gap-3 justify-center">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
>
|
|
||||||
<RotateCw size={16} />
|
|
||||||
Refresh Page
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => window.open("https://docs.netbird.io", "_blank")}
|
|
||||||
>
|
|
||||||
<BookText size={16} />
|
|
||||||
Documentation
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<PoweredByNetBird />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -108,14 +108,25 @@ func ServeHTTP(w http.ResponseWriter, r *http.Request, data any, statusCode ...i
|
|||||||
w.Write(buf.Bytes())
|
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.
|
// 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{
|
ServeHTTP(w, r, map[string]any{
|
||||||
"page": "error",
|
"page": "error",
|
||||||
"error": map[string]any{
|
"error": map[string]any{
|
||||||
"code": code,
|
"code": code,
|
||||||
"title": title,
|
"title": title,
|
||||||
"message": message,
|
"message": message,
|
||||||
|
"proxy": status.Proxy,
|
||||||
|
"peer": status.Peer,
|
||||||
|
"destination": status.Destination,
|
||||||
|
"requestId": requestID,
|
||||||
},
|
},
|
||||||
}, code)
|
}, code)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user