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:
Rūdolfs Ošiņš
2026-03-06 15:38:00 -06:00
parent fa85596ba1
commit 73408cd826
11 changed files with 155 additions and 36 deletions
+2 -1
View File
@@ -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;
+27 -1
View File
@@ -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>
+1 -2
View File
@@ -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>
+31 -25
View File
@@ -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>
+11 -2
View File
@@ -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 {
+1 -2
View File
@@ -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();
}
+13
View File
@@ -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",
+1 -1
View File
@@ -77,5 +77,5 @@
</div>
{/if}
{:catch error}
{router.push(handleError(error, utils.baseUrlToString(baseUrl)))}
{router.push(handleError(error, baseUrl))}
{/await}
+1 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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();
});
+66
View File
@@ -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(