mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 15:26:40 +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.
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
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" />
|
||||
<title>NetBird Service</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<script type="module" crossorigin src="/assets/index-ClfM9m3s.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/style-B1NSEbha.css">
|
||||
<script type="module" crossorigin src="/assets/index-BWSM6sR5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/style-IH-yd16d.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user