feat: add error boundary
This commit is contained in:
+27
-26
@@ -10,7 +10,8 @@ import Footer from "@components/UI/Footer.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { Dashboard } from "@pages/Dashboard/index.tsx";
|
||||
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { ErrorPage } from "./components/UI/ErrorPage.tsx";
|
||||
import { MapProvider } from "react-map-gl/maplibre";
|
||||
|
||||
export const App = () => {
|
||||
@@ -21,7 +22,7 @@ export const App = () => {
|
||||
const device = getDevice(selectedDevice);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
<NewDeviceDialog
|
||||
open={connectDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -29,32 +30,32 @@ export const App = () => {
|
||||
}}
|
||||
/>
|
||||
<Toaster />
|
||||
<MapProvider>
|
||||
<DeviceWrapper device={device}>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary">
|
||||
<div className="flex grow">
|
||||
<DeviceSelector />
|
||||
<div className="flex grow flex-col">
|
||||
{device
|
||||
? (
|
||||
<div className="flex h-screen">
|
||||
<DialogManager />
|
||||
<KeyBackupReminder />
|
||||
<CommandPalette />
|
||||
<DeviceWrapper device={device}>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary">
|
||||
<div className="flex grow">
|
||||
<DeviceSelector />
|
||||
<div className="flex grow flex-col">
|
||||
{device
|
||||
? (
|
||||
<div className="flex h-screen">
|
||||
<DialogManager />
|
||||
<KeyBackupReminder />
|
||||
<CommandPalette />
|
||||
<MapProvider>
|
||||
<PageRouter />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Dashboard />
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MapProvider>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Dashboard />
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DeviceWrapper>
|
||||
</MapProvider>
|
||||
</>
|
||||
</div>
|
||||
</DeviceWrapper>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
+35
-7
@@ -4,16 +4,44 @@ import ChannelsPage from "@pages/Channels.tsx";
|
||||
import ConfigPage from "@pages/Config/index.tsx";
|
||||
import MessagesPage from "@pages/Messages.tsx";
|
||||
import NodesPage from "@pages/Nodes.tsx";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { ErrorPage } from "./components/UI/ErrorPage";
|
||||
|
||||
export const ErrorBoundaryWrapper = ({
|
||||
children,
|
||||
}: { children: React.ReactNode }) => (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>{children}</ErrorBoundary>
|
||||
);
|
||||
|
||||
export const PageRouter = () => {
|
||||
const { activePage } = useDevice();
|
||||
return (
|
||||
<>
|
||||
{activePage === "messages" && <MessagesPage />}
|
||||
{activePage === "map" && <MapPage />}
|
||||
{activePage === "config" && <ConfigPage />}
|
||||
{activePage === "channels" && <ChannelsPage />}
|
||||
{activePage === "nodes" && <NodesPage />}
|
||||
</>
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
{activePage === "messages" && (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
<MessagesPage />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
{activePage === "map" && (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
<MapPage />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
{activePage === "config" && (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
<ConfigPage />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
{activePage === "channels" && (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
<ChannelsPage />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
{activePage === "nodes" && (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
<NodesPage />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from "@components/Form/DynamicFormField.tsx";
|
||||
import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { H4 } from "@components/UI/Typography/H4.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import {
|
||||
type Control,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
type SubmitHandler,
|
||||
useForm,
|
||||
} from "react-hook-form";
|
||||
import { Heading } from "../UI/Typography/Heading";
|
||||
|
||||
interface DisabledBy<T> {
|
||||
fieldName: Path<T>;
|
||||
@@ -95,7 +95,9 @@ export function DynamicForm<T extends FieldValues>({
|
||||
{fieldGroups.map((fieldGroup) => (
|
||||
<div key={fieldGroup.label} className="space-y-8 sm:space-y-5">
|
||||
<div>
|
||||
<H4 className="font-medium">{fieldGroup.label}</H4>
|
||||
<Heading as="h4" className="font-medium">
|
||||
{fieldGroup.label}
|
||||
</Heading>
|
||||
<Subtle>{fieldGroup.description}</Subtle>
|
||||
<Subtle className="font-semibold">{fieldGroup?.notes}</Subtle>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Separator } from "../../UI/Seperator.tsx";
|
||||
import { H5 } from "@app/components/UI/Typography/H5.tsx";
|
||||
import { Separator } from "@app/components/UI/Seperator.tsx";
|
||||
import { Heading } from "@app/components/UI/Typography/Heading.tsx";
|
||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
||||
import { formatQuantity } from "../../../core/utils/string.ts";
|
||||
import { Avatar } from "../../UI/Avatar.tsx";
|
||||
import { formatQuantity } from "@app/core/utils/string.ts";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
@@ -34,7 +34,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
<div className="dark:text-slate-900 p-1">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
||||
<Avatar text={node.user?.shortName} />
|
||||
<Avatar text={node.user?.shortName ?? "UNK"} />
|
||||
|
||||
<div>
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0
|
||||
@@ -64,24 +64,23 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<H5>{name}</H5>
|
||||
<Heading as="h5">{name}</Heading>
|
||||
|
||||
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>}
|
||||
|
||||
{!!node.deviceMetrics?.batteryLevel && (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
title={`${
|
||||
node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown"
|
||||
} volts`}
|
||||
title={`${node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown"
|
||||
} volts`}
|
||||
>
|
||||
{node.deviceMetrics?.batteryLevel > 100
|
||||
? <BatteryChargingIcon size={22} />
|
||||
: node.deviceMetrics?.batteryLevel > 80
|
||||
? <BatteryFullIcon size={22} />
|
||||
: node.deviceMetrics?.batteryLevel > 20
|
||||
? <BatteryMediumIcon size={22} />
|
||||
: <BatteryLowIcon size={22} />}
|
||||
? <BatteryFullIcon size={22} />
|
||||
: node.deviceMetrics?.batteryLevel > 20
|
||||
? <BatteryMediumIcon size={22} />
|
||||
: <BatteryLowIcon size={22} />}
|
||||
<Subtle aria-label="Battery">
|
||||
{node.deviceMetrics?.batteryLevel > 100
|
||||
? "Charging"
|
||||
|
||||
@@ -146,7 +146,7 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
{!lastMsgSameUser
|
||||
? (
|
||||
<div className="flex place-items-center gap-2 mb-1">
|
||||
<Avatar text={messageUser?.shortName} />
|
||||
<Avatar text={messageUser?.shortName ?? "UNK"} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{messageUser?.longName}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import newGithubIssueUrl from "@app/core/utils/github";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Heading } from "./Typography/Heading";
|
||||
import { Link } from "./Typography/Link";
|
||||
import { P } from "./Typography/P";
|
||||
|
||||
export function ErrorPage({ error }: { error: Error }) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<article>
|
||||
<section className="prose mx-auto mb-20 mt-28 max-w-prose px-8 text-2xl transition-all duration-150 ease-linear space-y-2">
|
||||
<Heading as="h2" className="text-text-primary">
|
||||
This is a little embarrassing...
|
||||
</Heading>
|
||||
<P>
|
||||
We are really sorry but an error occured in the web client that caused
|
||||
it to crash. This is not supposed to happen and we are working hard to
|
||||
fix it.
|
||||
</P>
|
||||
<P>
|
||||
The best way to prevent this from happening again to you or anyone
|
||||
else is to report the issue to us.
|
||||
</P>
|
||||
<P>Please include the following information in your report:</P>
|
||||
<ul className="list-disc list-inside text-sm">
|
||||
<li>What you were doing when the error occured</li>
|
||||
<li>What you expected to happen</li>
|
||||
<li>What actually happened</li>
|
||||
<li>Any other information you think might be relevant</li>
|
||||
</ul>
|
||||
<P>
|
||||
You can report the issue to our{" "}
|
||||
<Link
|
||||
href={newGithubIssueUrl({
|
||||
repoUrl: "https://github.com/meshtastic/web",
|
||||
template: "bug.yml",
|
||||
title: "[Bug]: An unhandled error occurred. <Add details here>",
|
||||
logs: error?.stack,
|
||||
})}
|
||||
>
|
||||
Github
|
||||
</Link>
|
||||
<ExternalLink size={24} className="inline-block ml-2" />
|
||||
</P>
|
||||
<P>
|
||||
Return to the <Link href="/">dashboard</Link>
|
||||
</P>
|
||||
|
||||
<details className="mt-6 text-md">
|
||||
<summary className="cursor-pointer">Error Details</summary>
|
||||
<span className="block text-sm mt-4 overflow-auto">
|
||||
{error?.message ? (
|
||||
<>
|
||||
<label htmlFor="message">Error message:</label>
|
||||
<pre
|
||||
id="message"
|
||||
className="w-full text-slate-400"
|
||||
>{`${error.message}`}</pre>
|
||||
</>
|
||||
) : null}
|
||||
{error?.stack ? (
|
||||
<>
|
||||
<label htmlFor="stack">Stack trace:</label>
|
||||
<pre
|
||||
id="stack"
|
||||
className="w-full text-slate-400"
|
||||
>{`${error.stack}`}</pre>
|
||||
</>
|
||||
) : null}
|
||||
{!error?.message && !error?.stack ? (
|
||||
<pre className=" w-full text-slate-400">{error.toString()}</pre>
|
||||
) : null}
|
||||
</span>
|
||||
</details>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { H4 } from "@components/UI/Typography/H4.tsx";
|
||||
import { Heading } from "../Typography/Heading";
|
||||
|
||||
export interface SidebarSectionProps {
|
||||
label: string;
|
||||
@@ -11,7 +11,9 @@ export const SidebarSection = ({
|
||||
children,
|
||||
}: SidebarSectionProps) => (
|
||||
<div className="px-4 py-2">
|
||||
<H4 className="mb-3 ml-2">{title}</H4>
|
||||
<Heading as="h4" className="mb-3 ml-2">
|
||||
{title}
|
||||
</Heading>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface H1Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const H1 = ({ children }: H1Props) => (
|
||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface H2Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const H2 = ({ children }: H2Props) => (
|
||||
<h2 className="scroll-m-20 border-b border-b-slate-200 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 dark:border-b-slate-700">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface H3Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const H3 = ({ children }: H3Props) => (
|
||||
<h3 className="scroll-m-20 text-2xl font-semibold tracking-tight">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
@@ -1,17 +0,0 @@
|
||||
import { cn } from "@app/core/utils/cn.ts";
|
||||
|
||||
export interface H4Props {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const H4 = ({ className, children }: H4Props) => (
|
||||
<h4
|
||||
className={cn(
|
||||
"scroll-m-20 text-xl font-semibold tracking-tight",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { cn } from "@app/core/utils/cn.ts";
|
||||
|
||||
export interface H5Props {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const H5 = ({ className, children }: H5Props) => (
|
||||
<h5
|
||||
className={cn("scroll-m-20 text-lg font-medium tracking-tight", className)}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import type React from "react";
|
||||
|
||||
const headingStyles = {
|
||||
h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
|
||||
h2: "scroll-m-20 border-b border-b-slate-200 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 dark:border-b-slate-700",
|
||||
h3: "scroll-m-20 text-2xl font-semibold tracking-tight",
|
||||
h4: "scroll-m-20 text-xl font-semibold tracking-tight",
|
||||
h5: "scroll-m-20 text-lg font-medium tracking-tight",
|
||||
};
|
||||
|
||||
interface HeadingProps {
|
||||
as?: "h1" | "h2" | "h3" | "h4" | "h5";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Heading = ({
|
||||
as: Component = "h1",
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: HeadingProps) => {
|
||||
const baseStyles = headingStyles[Component] || headingStyles.h1;
|
||||
|
||||
return (
|
||||
<Component className={`${baseStyles} ${className}`} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
interface RepoIdentifier {
|
||||
user: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
interface GithubIssueUrlOptions extends Partial<RepoIdentifier> {
|
||||
repoUrl?: string;
|
||||
body?: string;
|
||||
title?: string;
|
||||
labels?: string[];
|
||||
template?: string;
|
||||
assignee?: string;
|
||||
projects?: string[];
|
||||
logs?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
type ValidatedOptions = {
|
||||
repoUrl: string;
|
||||
} & Omit<GithubIssueUrlOptions, "repoUrl" | "user" | "repo">;
|
||||
|
||||
const VALID_PARAMS = [
|
||||
"body",
|
||||
"title",
|
||||
"labels",
|
||||
"template",
|
||||
"assignee",
|
||||
"projects",
|
||||
"version",
|
||||
"logs",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Generates a URL for creating a new GitHub issue
|
||||
* @param options Configuration options for the GitHub issue URL
|
||||
* @returns A formatted URL string for creating a new GitHub issue
|
||||
* @throws {Error} If repository information is missing or invalid
|
||||
* @throws {TypeError} If labels or projects are not arrays when provided
|
||||
*/
|
||||
export default function newGithubIssueUrl(
|
||||
options: GithubIssueUrlOptions = {},
|
||||
): string {
|
||||
const validatedOptions = validateOptions(options);
|
||||
const url = new URL(`${validatedOptions.repoUrl}/issues/new`);
|
||||
|
||||
for (const key of VALID_PARAMS) {
|
||||
const value = validatedOptions[key];
|
||||
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((key === "labels" || key === "projects") && Array.isArray(value)) {
|
||||
url.searchParams.set(key, value.join(","));
|
||||
continue;
|
||||
}
|
||||
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function validateOptions(options: GithubIssueUrlOptions): ValidatedOptions {
|
||||
const repoUrl =
|
||||
options.repoUrl ??
|
||||
(options.user && options.repo
|
||||
? `https://github.com/${options.user}/${options.repo}`
|
||||
: undefined);
|
||||
|
||||
if (!repoUrl) {
|
||||
throw new Error(
|
||||
"You need to specify either the `repoUrl` option or both the `user` and `repo` options",
|
||||
);
|
||||
}
|
||||
|
||||
for (const key of ["labels", "projects"] as const) {
|
||||
const value = options[key];
|
||||
if (value !== undefined && !Array.isArray(value)) {
|
||||
throw new TypeError(`The \`${key}\` option should be an array`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
repoUrl,
|
||||
};
|
||||
}
|
||||
@@ -78,6 +78,10 @@
|
||||
::file-selector-button {
|
||||
border-color: var(--color-slate-200, currentColor);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Heading } from "../../components/UI/Typography/Heading.tsx";
|
||||
import { useAppStore } from "@app/core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@app/core/stores/deviceStore.ts";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Separator } from "@components/UI/Seperator.tsx";
|
||||
import { H3 } from "@components/UI/Typography/H3.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import {
|
||||
BluetoothIcon,
|
||||
@@ -25,7 +25,7 @@ export const Dashboard = () => {
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<H3>Connected Devices</H3>
|
||||
<Heading as="h3">Connected Devices</Heading>
|
||||
<Subtle>Manage, connect and disconnect devices</Subtle>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,17 +71,17 @@ export const Dashboard = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="flex gap-2 text-sm text-slate-500">
|
||||
<UsersIcon
|
||||
size={20}
|
||||
className="text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{device.nodes.size === 0
|
||||
? 0
|
||||
: device.nodes.size - 1}
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="flex gap-2 text-sm text-slate-500">
|
||||
<UsersIcon
|
||||
size={20}
|
||||
className="text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{device.nodes.size === 0
|
||||
? 0
|
||||
: device.nodes.size - 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -96,10 +96,10 @@ export const Dashboard = () => {
|
||||
size={48}
|
||||
className="mx-auto text-text-secondary"
|
||||
/>
|
||||
<H3>No Devices</H3>
|
||||
<Heading as="h3">No Devices</Heading>
|
||||
<Subtle>Connect at least one device to get started</Subtle>
|
||||
<Button
|
||||
className="gap-2 dark:bg-white dark:text-slate-900 dark:hover:text-slate-100"
|
||||
className="gap-2"
|
||||
variant="default"
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user