diff --git a/frontend/src/app/(navfooter)/error.tsx b/frontend/src/app/(navfooter)/error.tsx index 1f120aa9..2cfae610 100644 --- a/frontend/src/app/(navfooter)/error.tsx +++ b/frontend/src/app/(navfooter)/error.tsx @@ -2,6 +2,6 @@ "use client" -import Error from "../error" +import ErrorPage from "../error" -export default Error +export default ErrorPage diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx index 38956884..537969b7 100644 --- a/frontend/src/app/error.tsx +++ b/frontend/src/app/error.tsx @@ -7,22 +7,36 @@ import { SyncIcon } from "@primer/octicons-react" import Button from "@/components/Button" import ErrorBoundary from "@/components/ErrorBoundary" import SetPageTitle from "@/components/SetPageTitle" +import { RequestFailedError } from "@/lib/api" -export const metadata = { - title: "Error", +type ErrorPageProps = {error: Error, reset: () => void }; + +function NetworkErrorPage({ error, reset }: ErrorPageProps) { + return <> + +
+
+

We're having some trouble reaching the backend

+ +
+ {error.toString()} +
+ +

+ If your internet connection is okay, we're probably down for maintenance, and will be back shortly. If this issue persists - let us know. +

+ + + + +
+
+ } -export default function Error({ - error, - reset, -}: { - error: Error - reset: () => void -}) { - useEffect(() => { - console.error(error) - }, [error]) - +function UnexpectedErrorPage({ error, reset }: ErrorPageProps) { return <>
@@ -46,3 +60,15 @@ export default function Error({
} + +export default function ErrorPage({ error, reset }: ErrorPageProps) { + useEffect(() => { + console.error(error) + }, [error]) + + return error instanceof RequestFailedError ? : +} + +export const metadata = { + title: "Error", +} diff --git a/frontend/src/app/scratch/[slug]/ScratchEditor.tsx b/frontend/src/app/scratch/[slug]/ScratchEditor.tsx index 8fa6522e..3ce7c0b7 100644 --- a/frontend/src/app/scratch/[slug]/ScratchEditor.tsx +++ b/frontend/src/app/scratch/[slug]/ScratchEditor.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" -import useSWR from "swr" +import useSWR, { Middleware, SWRConfig } from "swr" import Scratch from "@/components/Scratch" import useWarnBeforeScratchUnload from "@/components/Scratch/hooks/useWarnBeforeScratchUnload" @@ -18,13 +18,7 @@ function ScratchPageTitle({ scratch }: { scratch: api.Scratch }) { return } -export interface Props { - initialScratch: api.Scratch - parentScratch?: api.Scratch - initialCompilation?: api.Compilation -} - -export default function ScratchEditor({ initialScratch, parentScratch, initialCompilation }: Props) { +function ScratchEditorInner({ initialScratch, parentScratch, initialCompilation, offline }: Props) { const [scratch, setScratch] = useState(initialScratch) useWarnBeforeScratchUnload(scratch) @@ -75,7 +69,42 @@ export default function ScratchEditor({ initialScratch, parentScratch, initialCo return { ...scratch, ...partial } }) }} + offline={offline} /> } + +export interface Props { + initialScratch: api.Scratch + parentScratch?: api.Scratch + initialCompilation?: api.Compilation + offline?: boolean +} + +export default function ScratchEditor(props: Props) { + const [offline, setOffline] = useState(false) + + const offlineMiddleware: Middleware = _useSWRNext => { + return (key, fetcher, config) => { + let swr = _useSWRNext(key, fetcher, config) + + if (swr.error instanceof api.RequestFailedError) { + setOffline(true) + swr = Object.assign({}, swr, { error: null }) + } + + return swr + } + } + + const onSuccess = () => { + setOffline(false) + } + + return <> + + + + +} diff --git a/frontend/src/components/Scratch/Scratch.tsx b/frontend/src/components/Scratch/Scratch.tsx index dc3045d7..63b8cc0d 100644 --- a/frontend/src/components/Scratch/Scratch.tsx +++ b/frontend/src/components/Scratch/Scratch.tsx @@ -116,6 +116,7 @@ export type Props = { onChange: (scratch: Partial) => void parentScratch?: api.Scratch initialCompilation?: Readonly + offline: boolean } export default function Scratch({ @@ -123,6 +124,7 @@ export default function Scratch({ onChange, parentScratch, initialCompilation, + offline, }: Props) { const container = useSize() const [layout, setLayout] = useState(undefined) @@ -286,6 +288,15 @@ export default function Scratch({ } } + const offlineOverlay = ( + offline ? <> +
+

The scratch editor is in offline mode. We're attempting to reconnect to the backend - as long as this tab is open, your work is safe.

+
+ + : <> + ) + return
@@ -306,5 +317,6 @@ export default function Scratch({ renderTab={renderTab} />} + {offlineOverlay}
} diff --git a/frontend/src/lib/api/request.ts b/frontend/src/lib/api/request.ts index c5eccb0d..24e6382b 100644 --- a/frontend/src/lib/api/request.ts +++ b/frontend/src/lib/api/request.ts @@ -33,6 +33,13 @@ export class ResponseError extends Error { } } +export class RequestFailedError extends Error { + constructor(message: string, url: string) { + super(`${message} (occured when fetching ${url})`) + this.name = "RequestFailedError" + } +} + export function normalizeUrl(url: string) { if (url.startsWith("/")) { url = API_BASE + url @@ -40,22 +47,36 @@ export function normalizeUrl(url: string) { return url } -export async function get(url: string) { +export async function errorHandledFetchJson(url: string, init?: RequestInit) { + let response: Response + url = normalizeUrl(url) - console.info("GET", url) + try { + response = await fetch(url, init) + } catch (error) { + if (error instanceof TypeError) { + throw new RequestFailedError(error.message, url) + } - const response = await fetch(url, { - ...commonOpts, - cache: "no-cache", - next: { revalidate: 10 }, - }) - - if (!response.ok) { - throw new ResponseError(response, await response.json()) + throw error } try { + if (response.status == 502) { + // We've received a "Gateway Unavailable" message from nginx. + // The backend's down. + throw new RequestFailedError("Backend gateway unavailable", url) + } + + if (!response.ok) { + throw new ResponseError(response, await response.json()) + } + + if (response.status == 204) { + return null + } + return await response.json() } catch (error) { if (error instanceof SyntaxError) { @@ -69,10 +90,17 @@ export async function get(url: string) { } } -export async function post(url: string, data: Json | FormData, method = "POST") { - url = normalizeUrl(url) +export async function get(url: string) { + console.info("GET", normalizeUrl(url)) + return await errorHandledFetchJson(url, { + ...commonOpts, + cache: "no-cache", + next: { revalidate: 10 }, + }) +} - console.info(method, url, data) +export async function post(url: string, data: Json | FormData, method = "POST") { + console.info(method, normalizeUrl(url), data) let body: string | FormData if (data instanceof FormData) { @@ -81,7 +109,7 @@ export async function post(url: string, data: Json | FormData, method = "POST") body = JSON.stringify(data) } - const response = await fetch(url, { + return await errorHandledFetchJson(url, { ...commonOpts, method, body, @@ -89,16 +117,6 @@ export async function post(url: string, data: Json | FormData, method = "POST") "Content-Type": "application/json", }, }) - - if (!response.ok) { - throw new ResponseError(response, await response.json()) - } - - if (response.status == 204) { - return null - } else { - return await response.json() - } } export async function patch(url: string, data: Json | FormData) {