mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-31 01:00:47 +08:00
Improve UX when a node is unreachable
When a saved node (e.g. localhost) is down, the app previously showed a dead-end "Node not found" page with no way to navigate away. Now the not-found page displays the seed selector inline with the error title, showing which node failed and letting users switch to a different one directly. The same treatment applies when navigating to a repo on an unreachable node.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { BaseUrl } from "@http-client";
|
||||
import type {
|
||||
ResponseError,
|
||||
ResponseParseError,
|
||||
@@ -18,7 +19,7 @@ interface BootingRoute {
|
||||
|
||||
export interface NotFoundRoute {
|
||||
resource: "notFound";
|
||||
params: { title: string };
|
||||
params: { title: string; description?: string; baseUrl?: BaseUrl };
|
||||
}
|
||||
|
||||
export type ErrorParam = Error | ResponseParseError | ResponseError | undefined;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { BaseUrl } from "@http-client";
|
||||
|
||||
import Layout from "@app/App/Layout.svelte";
|
||||
import IconLarge from "@app/components/IconLarge.svelte";
|
||||
import SeedSelector from "@app/views/nodes/SeedSelector.svelte";
|
||||
|
||||
export let title: string;
|
||||
export let description: string | undefined = undefined;
|
||||
export let baseUrl: BaseUrl | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -14,11 +19,32 @@
|
||||
gap: 1.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<Layout>
|
||||
<div class="container">
|
||||
<IconLarge name="desert" />
|
||||
<div class="title txt-heading-m">{title}</div>
|
||||
<div class="title-row txt-heading-m">
|
||||
<span>{title}</span>
|
||||
{#if baseUrl}
|
||||
<SeedSelector {baseUrl} compact />
|
||||
{/if}
|
||||
</div>
|
||||
{#if description}
|
||||
<div
|
||||
class="txt-body-m-regular"
|
||||
style:color="var(--color-text-tertiary)"
|
||||
style:text-align="center">
|
||||
{#each description.split("\n") as line}
|
||||
<div>{line}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { BaseUrl, NodeStats } from "@http-client";
|
||||
|
||||
import * as router from "@app/lib/router";
|
||||
import { baseUrlToString } from "@app/lib/utils";
|
||||
import { fetchRepoInfos } from "@app/components/RepoCard";
|
||||
import { handleError } from "@app/views/nodes/error";
|
||||
|
||||
@@ -196,6 +195,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
{:catch error}
|
||||
{router.push(handleError(error, baseUrlToString(baseUrl)))}
|
||||
{router.push(handleError(error, baseUrl))}
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import TextInput from "@app/components/TextInput.svelte";
|
||||
|
||||
export let baseUrl: BaseUrl;
|
||||
export let compact: boolean = false;
|
||||
|
||||
let expanded: boolean = false;
|
||||
let loading = false;
|
||||
@@ -108,14 +109,17 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="global-flex-item container" style:width="100%">
|
||||
<div
|
||||
class="global-flex-item container"
|
||||
style:width="100%"
|
||||
style:justify-content={compact ? "center" : "flex-start"}>
|
||||
<Popover
|
||||
bind:expanded
|
||||
popoverPositionTop="2.5rem"
|
||||
popoverPadding="0.25rem"
|
||||
popoverBorderRadius="var(--border-radius-md)">
|
||||
<div class="target" slot="toggle" title="Switch preferred seed" let:toggle>
|
||||
<div class="txt-body-m-semibold txt-overflow">
|
||||
<div class:txt-body-m-semibold={!compact} class="txt-overflow">
|
||||
{baseUrl.hostname}
|
||||
</div>
|
||||
<IconButton on:click={toggle} ariaLabel="Toggle seed selector dropdown">
|
||||
@@ -199,27 +203,29 @@
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
|
||||
<IconButton
|
||||
ariaLabel={some($bookmarkedSeeds, baseUrl) ||
|
||||
some(config.preferredSeeds, baseUrl)
|
||||
? "Remove bookmark"
|
||||
: "Add bookmark"}
|
||||
stopPropagation
|
||||
disabled={some(config.preferredSeeds, baseUrl)}
|
||||
title={some(config.preferredSeeds, baseUrl)
|
||||
? "Default seeds can't be removed"
|
||||
: undefined}
|
||||
on:click={() => {
|
||||
if (some($bookmarkedSeeds, baseUrl)) {
|
||||
removeBookmark(baseUrl);
|
||||
} else {
|
||||
addBookmark(baseUrl);
|
||||
}
|
||||
}}>
|
||||
{#if some($bookmarkedSeeds, baseUrl) || some(config.preferredSeeds, baseUrl)}
|
||||
<Icon name="bookmark-fill" />
|
||||
{:else}
|
||||
<Icon name="bookmark" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
{#if !compact}
|
||||
<IconButton
|
||||
ariaLabel={some($bookmarkedSeeds, baseUrl) ||
|
||||
some(config.preferredSeeds, baseUrl)
|
||||
? "Remove bookmark"
|
||||
: "Add bookmark"}
|
||||
stopPropagation
|
||||
disabled={some(config.preferredSeeds, baseUrl)}
|
||||
title={some(config.preferredSeeds, baseUrl)
|
||||
? "Default seeds can't be removed"
|
||||
: undefined}
|
||||
on:click={() => {
|
||||
if (some($bookmarkedSeeds, baseUrl)) {
|
||||
removeBookmark(baseUrl);
|
||||
} else {
|
||||
addBookmark(baseUrl);
|
||||
}
|
||||
}}>
|
||||
{#if some($bookmarkedSeeds, baseUrl) || some(config.preferredSeeds, baseUrl)}
|
||||
<Icon name="bookmark-fill" />
|
||||
{:else}
|
||||
<Icon name="bookmark" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { BaseUrl } from "@http-client";
|
||||
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
|
||||
|
||||
import { ResponseParseError, ResponseError } from "@http-client/lib/fetcher";
|
||||
import { baseUrlToString } from "@app/lib/utils";
|
||||
|
||||
export function handleError(
|
||||
error: Error | ResponseParseError | ResponseError,
|
||||
url: string,
|
||||
baseUrl: BaseUrl,
|
||||
): NotFoundRoute | ErrorRoute {
|
||||
const url = baseUrlToString(baseUrl);
|
||||
|
||||
if (error instanceof ResponseParseError) {
|
||||
return {
|
||||
resource: "error",
|
||||
@@ -30,7 +34,12 @@ export function handleError(
|
||||
) {
|
||||
return {
|
||||
resource: "notFound",
|
||||
params: { title: "Node not found" },
|
||||
params: {
|
||||
title: "Could not connect to",
|
||||
description:
|
||||
"The node may be offline or the address may be incorrect.\nSelect a different node to continue.",
|
||||
baseUrl,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
|
||||
import config from "@app/lib/config";
|
||||
import { HttpdClient } from "@http-client";
|
||||
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
|
||||
import { baseUrlToString } from "@app/lib/utils";
|
||||
import { handleError } from "@app/views/nodes/error";
|
||||
import { unreachableError } from "@app/views/repos/error";
|
||||
import { determineSeed } from "./SeedSelector";
|
||||
@@ -70,7 +69,7 @@ export async function loadNodeRoute(
|
||||
error instanceof ResponseError ||
|
||||
error instanceof ResponseParseError
|
||||
) {
|
||||
return handleError(error, baseUrlToString(api.baseUrl));
|
||||
return handleError(error, baseUrl);
|
||||
} else {
|
||||
return unreachableError();
|
||||
}
|
||||
|
||||
@@ -44,6 +44,19 @@ export function handleError(
|
||||
description: error.description,
|
||||
},
|
||||
};
|
||||
} else if (
|
||||
error instanceof TypeError &&
|
||||
error.message === "Failed to fetch"
|
||||
) {
|
||||
return {
|
||||
resource: "notFound",
|
||||
params: {
|
||||
title: "Could not connect to",
|
||||
description:
|
||||
"The node may be offline or the address may be incorrect.\nSelect a different node to continue.",
|
||||
baseUrl: route.node,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
resource: "error",
|
||||
|
||||
@@ -77,5 +77,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
{:catch error}
|
||||
{router.push(handleError(error, utils.baseUrlToString(baseUrl)))}
|
||||
{router.push(handleError(error, baseUrl))}
|
||||
{/await}
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function loadUserRoute({
|
||||
error instanceof ResponseError ||
|
||||
error instanceof ResponseParseError
|
||||
) {
|
||||
return handleError(error, utils.baseUrlToString(api.baseUrl));
|
||||
return handleError(error, api.baseUrl);
|
||||
} else {
|
||||
return unreachableError();
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ test("exceptions in production build", async ({ page }) => {
|
||||
// Wait for scripts to finish executing, there might be exceptions that
|
||||
// happen after the page has been painted.
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.getByText("Node not found")).toBeVisible();
|
||||
await expect(page.getByText("Could not connect to")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
defaultConfig,
|
||||
expect,
|
||||
shortNodeRemote,
|
||||
sourceBrowsingRid,
|
||||
test,
|
||||
} from "@tests/support/fixtures.js";
|
||||
|
||||
@@ -51,6 +52,71 @@ test("show pinned repositories", async ({ page }) => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("unreachable node shows error with seed selector", async ({ page }) => {
|
||||
await page.goto("/nodes/this.node.does.not.exist.xyz", {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
|
||||
// Shows the error title with the unreachable hostname.
|
||||
await expect(page.getByText("Could not connect to")).toBeVisible();
|
||||
await expect(page.getByText("this.node.does.not.exist.xyz")).toBeVisible();
|
||||
|
||||
// Shows the description.
|
||||
await expect(
|
||||
page.getByText("The node may be offline or the address may be incorrect."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Select a different node to continue."),
|
||||
).toBeVisible();
|
||||
|
||||
// Shows the seed selector dropdown toggle.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Toggle seed selector dropdown" }),
|
||||
).toBeVisible();
|
||||
|
||||
// Bookmark button is not shown in compact mode.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Add bookmark" }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("seed selector on not-found page allows navigating to a working node", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/nodes/this.node.does.not.exist.xyz", {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
|
||||
await expect(page.getByText("Could not connect to")).toBeVisible();
|
||||
|
||||
// Open the seed selector and navigate to the working local node.
|
||||
await page
|
||||
.getByRole("button", { name: "Toggle seed selector dropdown" })
|
||||
.click();
|
||||
await page.getByPlaceholder("seed.radicle.example").fill("localhost");
|
||||
await page.getByPlaceholder("seed.radicle.example").press("Enter");
|
||||
|
||||
// Should navigate to the working node.
|
||||
await expect(page).toHaveURL("/nodes/localhost");
|
||||
await expect(page.getByText("source-browsing")).toBeVisible();
|
||||
});
|
||||
|
||||
test("unreachable seed on repo page shows error with seed selector", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/nodes/this.node.does.not.exist.xyz/${sourceBrowsingRid}`, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
|
||||
await expect(page.getByText("Could not connect to")).toBeVisible();
|
||||
await expect(page.getByText("this.node.does.not.exist.xyz")).toBeVisible();
|
||||
|
||||
// Seed selector is available to navigate away.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Toggle seed selector dropdown" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("edit seed bookmarks", async ({ page }) => {
|
||||
// Proxy requests to seed.example.tld to the local test api.
|
||||
await page.route(
|
||||
|
||||
Reference in New Issue
Block a user