diff --git a/apps/web/package.json b/apps/web/package.json
index b7ae530b..2e4512b0 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -26,6 +26,7 @@
"dependencies": {
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index daffae7f..2ad79838 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -1,5 +1,10 @@
+import type { Network } from "@/types/config";
import type { PaginationOptions } from "@/types/pagination";
+type PaginationOptionsWithNetwork = PaginationOptions & {
+ network?: Network | null;
+};
+
const JSON_HEADERS = { "Content-Type": "application/json" } as const;
/**
@@ -44,10 +49,11 @@ export const apiPaths = {
/** Dealbot app config (network this instance monitors, job rates, etc). */
config: (): string => "/api/config",
/** Simple providers list, for dropdowns/filters. */
- providers: (options?: PaginationOptions): string => {
- const queryString = options
- ? buildQueryString(options as Record)
- : "";
+ providers: (options?: PaginationOptionsWithNetwork): string | null => {
+ if (!options?.network) {
+ return null;
+ }
+ const queryString = buildQueryString(options as Record);
return `/api/v1/providers${queryString}`;
},
} as const;
diff --git a/apps/web/src/components/shared/Header/index.tsx b/apps/web/src/components/shared/Header/index.tsx
index df9e18b1..31967a74 100644
--- a/apps/web/src/components/shared/Header/index.tsx
+++ b/apps/web/src/components/shared/Header/index.tsx
@@ -1,4 +1,4 @@
-import { ModeToggle, NetworkSwitcher, UIVersionToggle } from "@/components/shared";
+import { EnvironmentSwitcher, ModeToggle, UIVersionToggle } from "@/components/shared";
const Header = () => (
@@ -14,7 +14,7 @@ const Header = () => (
-
+
diff --git a/apps/web/src/components/shared/Network/EnvironmentSwitcher.test.tsx b/apps/web/src/components/shared/Network/EnvironmentSwitcher.test.tsx
new file mode 100644
index 00000000..529a59aa
--- /dev/null
+++ b/apps/web/src/components/shared/Network/EnvironmentSwitcher.test.tsx
@@ -0,0 +1,102 @@
+import { server } from "@test/mocks/server";
+import { render, screen, waitFor } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+import { MemoryRouter } from "react-router-dom";
+import { SWRConfig } from "swr";
+import { describe, expect, it } from "vitest";
+import EnvironmentSwitcher from "./EnvironmentSwitcher";
+
+const configUrl = "/api/config";
+
+function makeConfig(network: "mainnet" | "calibration") {
+ return {
+ networks: [
+ {
+ network,
+ dealsPerSpPerHour: 4,
+ retrievalsPerSpPerHour: 4,
+ dataSetCreationsPerSpPerHour: 0,
+ pullChecksPerSpPerHour: 0,
+ dataRetentionPollIntervalSeconds: 3600,
+ providersRefreshIntervalSeconds: 3600,
+ },
+ ],
+ };
+}
+
+// Each render gets a fresh SWR cache so cases setting different /api/config
+// responses don't reuse a value cached by a previous case.
+function renderSwitcher() {
+ return render(
+ new Map() }}>
+
+
+
+ ,
+ );
+}
+
+describe("EnvironmentSwitcher", () => {
+ it("links to Staging when current deployment monitors mainnet (Production)", async () => {
+ server.use(http.get(configUrl, () => HttpResponse.json(makeConfig("mainnet"))));
+
+ renderSwitcher();
+
+ const link = await screen.findByRole("link", { name: /Switch to Staging/i });
+ expect(link).toHaveAttribute("href", "https://staging.dealbot.filoz.org");
+ });
+
+ it("links to Production when current deployment monitors calibration (Staging)", async () => {
+ server.use(http.get(configUrl, () => HttpResponse.json(makeConfig("calibration"))));
+
+ renderSwitcher();
+
+ const link = await screen.findByRole("link", { name: /Switch to Production/i });
+ expect(link).toHaveAttribute("href", "https://dealbot.filoz.org");
+ });
+
+ it("shows loading skeleton initially", () => {
+ server.use(http.get(configUrl, () => new Promise(() => {})));
+
+ const { container } = renderSwitcher();
+
+ expect(container.querySelector(".animate-pulse")).toBeInTheDocument();
+ });
+
+ it("hides component when config fails to load", async () => {
+ server.use(http.get(configUrl, () => new HttpResponse(null, { status: 500 })));
+
+ const { container } = renderSwitcher();
+
+ await waitFor(() => {
+ expect(container.firstChild).toBeNull();
+ });
+ });
+
+ it("opens link with rel=noreferrer", async () => {
+ server.use(http.get(configUrl, () => HttpResponse.json(makeConfig("mainnet"))));
+
+ renderSwitcher();
+
+ const link = await screen.findByRole("link", { name: /Switch to Staging/i });
+ expect(link).toHaveAttribute("rel", "noreferrer");
+ });
+
+ it("shows amber dot when linking to Staging (calibration)", async () => {
+ server.use(http.get(configUrl, () => HttpResponse.json(makeConfig("mainnet"))));
+
+ const { container } = renderSwitcher();
+
+ await screen.findByRole("link", { name: /Switch to Staging/i });
+ expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
+ });
+
+ it("shows emerald dot when linking to Production (mainnet)", async () => {
+ server.use(http.get(configUrl, () => HttpResponse.json(makeConfig("calibration"))));
+
+ const { container } = renderSwitcher();
+
+ await screen.findByRole("link", { name: /Switch to Production/i });
+ expect(container.querySelector(".bg-emerald-500")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/shared/Network/EnvironmentSwitcher.tsx b/apps/web/src/components/shared/Network/EnvironmentSwitcher.tsx
new file mode 100644
index 00000000..1c297439
--- /dev/null
+++ b/apps/web/src/components/shared/Network/EnvironmentSwitcher.tsx
@@ -0,0 +1,35 @@
+import { ArrowLeftRight } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useActiveNetworks } from "@/hooks/useActiveNetworks";
+import { ENVIRONMENT_LABEL, NETWORK_DEPLOYMENT_URL, NETWORK_DOT_CLASS } from "./constants";
+
+/**
+ * Header control that links to the sibling deployment for the other environment.
+ *
+ * Environment is determined by which networks the current deployment monitors:
+ * - Active networks include mainnet → Production deployment → link to Staging
+ * - Active networks are calibration-only → Staging deployment → link to Production
+ */
+export default function EnvironmentSwitcher() {
+ const { activeNetworks, loading, error } = useActiveNetworks();
+
+ if (loading) {
+ return
;
+ }
+
+ if (error || activeNetworks.length === 0) return null;
+
+ const isProduction = activeNetworks.includes("mainnet");
+ const targetEnv = isProduction ? "calibration" : "mainnet";
+ const label = `Switch to ${ENVIRONMENT_LABEL[targetEnv]}`;
+
+ return (
+
+
+
+
+ {ENVIRONMENT_LABEL[targetEnv]}
+
+
+ );
+}
diff --git a/apps/web/src/components/shared/Network/NetworkBadge.tsx b/apps/web/src/components/shared/Network/NetworkBadge.tsx
index 15b77e7e..d5cfae98 100644
--- a/apps/web/src/components/shared/Network/NetworkBadge.tsx
+++ b/apps/web/src/components/shared/Network/NetworkBadge.tsx
@@ -1,16 +1,20 @@
-import { useNetworkConfig } from "@/hooks/useNetworkConfig";
+import type { Network } from "@/types/config";
import { NETWORK_DOT_CLASS, NETWORK_LABEL } from "./constants";
-function NetworkBadge({ className }: { className?: string }) {
- const { network, loading, error } = useNetworkConfig();
+interface NetworkBadgeProps {
+ network: Network | null;
+ loading?: boolean;
+ className?: string;
+}
+function NetworkBadge({ network, loading = false, className }: NetworkBadgeProps) {
if (loading) {
return (
);
}
- if (error || network === null) return null;
+ if (network === null) return null;
return (
new Map() }}>
-
-
-
- ,
- );
-}
-
describe("NetworkSwitcher", () => {
- it("shows current network and a link to switch to the other deployment (mainnet → calibration)", async () => {
- server.use(
- http.get(configUrl, () =>
- HttpResponse.json({
- network: "mainnet",
- jobs: {},
- }),
- ),
- );
-
- renderSwitcher();
-
- const switchLink = await screen.findByRole("link", { name: /Switch to Calibration/i });
- expect(switchLink).toHaveAttribute("href", "https://staging.dealbot.filoz.org");
+ it("renders nothing when only one network is active", () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
});
- it("offers switching to mainnet when current instance monitors calibration", async () => {
- server.use(
- http.get(configUrl, () =>
- HttpResponse.json({
- network: "calibration",
- jobs: {},
- }),
- ),
- );
-
- renderSwitcher();
-
- const switchLink = await screen.findByRole("link", { name: /Switch to Mainnet/i });
- expect(switchLink).toHaveAttribute("href", "https://dealbot.filoz.org");
+ it("renders a tab for each network when multiple are active", () => {
+ render( );
+ expect(screen.getByRole("tab", { name: /mainnet/i })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: /calibration/i })).toBeInTheDocument();
});
- it("shows loading state initially", () => {
- server.use(http.get(configUrl, () => new Promise(() => {})));
-
- const { container } = renderSwitcher();
-
- const loadingElement = container.querySelector(".animate-pulse");
- expect(loadingElement).toBeInTheDocument();
- expect(loadingElement).toHaveClass("bg-muted");
+ it("marks the selected network tab as aria-selected", () => {
+ render( );
+ expect(screen.getByRole("tab", { name: /calibration/i })).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("tab", { name: /mainnet/i })).toHaveAttribute("aria-selected", "false");
});
- it("hides component when network config fails to load", async () => {
- server.use(
- http.get(configUrl, () => {
- return new HttpResponse(null, { status: 500 });
- }),
- );
+ it("calls onChange with the clicked network", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render( );
- const { container } = renderSwitcher();
-
- await waitFor(() => {
- expect(container.firstChild).toBeNull();
- });
+ await user.click(screen.getByRole("tab", { name: /calibration/i }));
+ expect(onChange).toHaveBeenCalledWith("calibration");
});
- it("opens link in new tab with proper security attributes", async () => {
- server.use(
- http.get(configUrl, () =>
- HttpResponse.json({
- network: "mainnet",
- jobs: {},
- }),
- ),
+ it("shows emerald dot for mainnet tab", () => {
+ const { container } = render(
+ ,
);
-
- renderSwitcher();
-
- const switchLink = await screen.findByRole("link", { name: /Switch to Calibration/i });
- expect(switchLink).toHaveAttribute("rel", "noreferrer");
+ expect(container.querySelector(".bg-emerald-500")).toBeInTheDocument();
});
- it("displays correct network indicator color for calibration", async () => {
- server.use(
- http.get(configUrl, () =>
- HttpResponse.json({
- network: "calibration",
- jobs: {},
- }),
- ),
+ it("shows amber dot for calibration tab", () => {
+ const { container } = render(
+ ,
);
-
- const { container } = renderSwitcher();
-
- await screen.findByRole("link", { name: /Switch to Mainnet/i });
-
- const networkDot = container.querySelector(".bg-emerald-500");
- expect(networkDot).toBeInTheDocument();
+ expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
});
- it("displays correct network indicator color for mainnet", async () => {
- server.use(
- http.get(configUrl, () =>
- HttpResponse.json({
- network: "mainnet",
- jobs: {},
- }),
- ),
- );
-
- const { container } = renderSwitcher();
-
- await screen.findByRole("link", { name: /Switch to Calibration/i });
-
- const networkDot = container.querySelector(".bg-amber-500");
- expect(networkDot).toBeInTheDocument();
+ it("has role=tablist on the container", () => {
+ render( );
+ expect(screen.getByRole("tablist")).toBeInTheDocument();
});
});
diff --git a/apps/web/src/components/shared/Network/NetworkSwitcher.tsx b/apps/web/src/components/shared/Network/NetworkSwitcher.tsx
index fe1d0559..156852cc 100644
--- a/apps/web/src/components/shared/Network/NetworkSwitcher.tsx
+++ b/apps/web/src/components/shared/Network/NetworkSwitcher.tsx
@@ -1,33 +1,30 @@
-import { ArrowLeftRight } from "lucide-react";
-import { Link } from "react-router-dom";
-import { Button } from "@/components/ui/button";
-import { useNetworkConfig } from "@/hooks/useNetworkConfig";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Network } from "@/types/config";
-import { NETWORK_DEPLOYMENT_URL, NETWORK_DOT_CLASS, NETWORK_LABEL } from "./constants";
+import { NETWORK_DOT_CLASS, NETWORK_LABEL } from "./constants";
-const otherNetwork = (network: Network): Network => (network === "mainnet" ? "calibration" : "mainnet");
+interface NetworkSwitcherProps {
+ networks: Network[];
+ selected: Network;
+ onChange: (network: Network) => void;
+}
/**
- * Provides a link to the sibling deployment for the other network.
+ * In-page tab control for switching between the active networks of a
+ * multi-network deployment. Renders nothing when only one network is active.
*/
-export default function NetworkSwitcher() {
- const { network, loading, error } = useNetworkConfig();
-
- if (loading) {
- return
;
- }
-
- if (error || network === null) return null;
+export default function NetworkSwitcher({ networks, selected, onChange }: NetworkSwitcherProps) {
+ if (networks.length <= 1) return null;
- const other = otherNetwork(network);
- const label = `Switch to ${NETWORK_LABEL[other]}`;
return (
-
-
-
-
- {NETWORK_LABEL[other]}
-
-
+ onChange(v as Network)}>
+
+ {networks.map((network) => (
+
+
+ {NETWORK_LABEL[network]}
+
+ ))}
+
+
);
}
diff --git a/apps/web/src/components/shared/Network/constants.ts b/apps/web/src/components/shared/Network/constants.ts
index 7b3d8f20..1e8bd927 100644
--- a/apps/web/src/components/shared/Network/constants.ts
+++ b/apps/web/src/components/shared/Network/constants.ts
@@ -1,16 +1,29 @@
import type { Network } from "@/types/config";
+/** External URLs for the sibling deployment of each environment. */
export const NETWORK_DEPLOYMENT_URL: Record = {
mainnet: "https://dealbot.filoz.org",
calibration: "https://staging.dealbot.filoz.org",
};
+/** Human-readable network name used in the in-page network switcher and badges. */
export const NETWORK_LABEL: Record = {
mainnet: "Mainnet",
calibration: "Calibration",
};
+/** Tailwind dot color per network. */
export const NETWORK_DOT_CLASS: Record = {
mainnet: "bg-emerald-500",
calibration: "bg-amber-500",
};
+
+/**
+ * Environment label per network.
+ * A deployment whose active networks include mainnet is "Production";
+ * a calibration-only deployment is "Staging".
+ */
+export const ENVIRONMENT_LABEL: Record = {
+ mainnet: "Production",
+ calibration: "Staging",
+};
diff --git a/apps/web/src/components/shared/index.ts b/apps/web/src/components/shared/index.ts
index 29c40bca..4d1809bd 100644
--- a/apps/web/src/components/shared/index.ts
+++ b/apps/web/src/components/shared/index.ts
@@ -2,9 +2,20 @@ import ComingSoon from "./ComingSoon";
import Footer from "./Footer";
import Header from "./Header";
import ModeToggle from "./ModeToggle";
+import EnvironmentSwitcher from "./Network/EnvironmentSwitcher";
import NetworkBadge from "./Network/NetworkBadge";
import NetworkSwitcher from "./Network/NetworkSwitcher";
import ThemeProvider from "./ThemeProvider";
import UIVersionToggle from "./UIVersionToggle";
-export { ComingSoon, Footer, Header, ModeToggle, NetworkBadge, NetworkSwitcher, ThemeProvider, UIVersionToggle };
+export {
+ ComingSoon,
+ EnvironmentSwitcher,
+ Footer,
+ Header,
+ ModeToggle,
+ NetworkBadge,
+ NetworkSwitcher,
+ ThemeProvider,
+ UIVersionToggle,
+};
diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx
new file mode 100644
index 00000000..4a59fd1d
--- /dev/null
+++ b/apps/web/src/components/ui/tabs.tsx
@@ -0,0 +1,69 @@
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Tabs({ className, orientation = "horizontal", ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps & VariantProps) {
+ return (
+
+ );
+}
+
+function TabsTrigger({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function TabsContent({ className, ...props }: React.ComponentProps) {
+ return ;
+}
+
+export { Tabs, TabsContent, TabsList, tabsListVariants, TabsTrigger };
diff --git a/apps/web/src/hooks/useNetworkConfig.ts b/apps/web/src/hooks/useActiveNetworks.ts
similarity index 65%
rename from apps/web/src/hooks/useNetworkConfig.ts
rename to apps/web/src/hooks/useActiveNetworks.ts
index 5c0f1f57..60f65b94 100644
--- a/apps/web/src/hooks/useNetworkConfig.ts
+++ b/apps/web/src/hooks/useActiveNetworks.ts
@@ -2,16 +2,17 @@ import useSWR from "swr";
import { apiPaths, fetcher } from "@/api/client";
import type { AppConfigResponse, Network } from "@/types/config";
-interface UseNetworkConfigReturn {
- network: Network | null;
+interface UseActiveNetworksReturn {
+ activeNetworks: Network[];
loading: boolean;
error: string | null;
}
/**
- * Fetch the dealbot app config and expose the network this instance monitors.
+ * Fetches the dealbot app config and returns the list of networks this
+ * deployment is actively monitoring (e.g. ["calibration"] or ["calibration", "mainnet"]).
*/
-export function useNetworkConfig(): UseNetworkConfigReturn {
+export function useActiveNetworks(): UseActiveNetworksReturn {
const { data, error, isLoading } = useSWR(apiPaths.config(), fetcher, {
revalidateIfStale: false,
revalidateOnFocus: false,
@@ -19,7 +20,7 @@ export function useNetworkConfig(): UseNetworkConfigReturn {
});
return {
- network: data?.network ?? null,
+ activeNetworks: data?.networks?.map((n) => n.network) ?? [],
loading: isLoading,
error: toErrorMessage(error, "Failed to fetch app config"),
};
diff --git a/apps/web/src/hooks/useProvidersList.ts b/apps/web/src/hooks/useProvidersList.ts
index 1bca22aa..175ba188 100644
--- a/apps/web/src/hooks/useProvidersList.ts
+++ b/apps/web/src/hooks/useProvidersList.ts
@@ -1,5 +1,6 @@
import useSWR from "swr";
import { apiPaths, fetcher } from "@/api/client";
+import type { Network } from "@/types/config";
import type { ProvidersListResponseWithoutMetrics } from "@/types/providers";
interface UseProvidersListReturn {
@@ -8,9 +9,9 @@ interface UseProvidersListReturn {
error: string | null;
}
-export function useProvidersList(offset = 0, limit = 20): UseProvidersListReturn {
+export function useProvidersList(offset = 0, limit = 20, network: Network | null): UseProvidersListReturn {
const { data, error, isLoading } = useSWR(
- apiPaths.providers({ offset, limit }),
+ apiPaths.providers({ offset, limit, network }),
fetcher,
{
revalidateOnFocus: false,
diff --git a/apps/web/src/hooks/useSelectedNetwork.ts b/apps/web/src/hooks/useSelectedNetwork.ts
new file mode 100644
index 00000000..3812aa01
--- /dev/null
+++ b/apps/web/src/hooks/useSelectedNetwork.ts
@@ -0,0 +1,35 @@
+import { useCallback } from "react";
+import { useSearchParams } from "react-router-dom";
+import type { Network } from "@/types/config";
+
+/**
+ * Derives the selected network from the `?network=` URL search param, falling
+ * back to `activeNetworks[0]` when the param is absent or invalid.
+ *
+ * Returns the selected network and a stable setter that writes the param to the
+ * URL (replacing history so network switches don't stack in the back button).
+ *
+ * Returns `[null, setter]` while `activeNetworks` is still empty (loading).
+ */
+export function useSelectedNetwork(activeNetworks: Network[]): [Network | null, (n: Network) => void] {
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const param = searchParams.get("network") as Network | null;
+ const selected = param !== null && activeNetworks.includes(param) ? param : (activeNetworks[0] ?? null);
+
+ const setSelectedNetwork = useCallback(
+ (network: Network) => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ next.set("network", network);
+ return next;
+ },
+ { replace: true },
+ );
+ },
+ [setSearchParams],
+ );
+
+ return [selected, setSelectedNetwork];
+}
diff --git a/apps/web/src/pages/Landing.test.tsx b/apps/web/src/pages/Landing.test.tsx
index 4f8cc39d..ec746f14 100644
--- a/apps/web/src/pages/Landing.test.tsx
+++ b/apps/web/src/pages/Landing.test.tsx
@@ -1,6 +1,8 @@
import { render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
-import type { ProvidersListResponseWithoutMetrics } from "@/types/providers";
+import { MemoryRouter } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Network } from "@/types/config";
+import type { Provider, ProvidersListResponseWithoutMetrics } from "@/types/providers";
const mockUseProvidersList =
vi.fn<
@@ -10,18 +12,31 @@ const mockUseProvidersList =
error: string | null;
}
>();
+const mockUseActiveNetworks =
+ vi.fn<
+ () => {
+ activeNetworks: Network[];
+ loading: boolean;
+ error: string | null;
+ }
+ >();
+const mockUseSelectedNetwork = vi.fn<() => [Network | null, (network: Network) => void]>();
vi.mock("@/hooks/useProvidersList", () => ({
useProvidersList: (...args: unknown[]) => mockUseProvidersList(...args),
}));
-vi.mock("@/hooks/useNetworkConfig", () => ({
- useNetworkConfig: () => ({ network: "mainnet", loading: false, error: null }),
+vi.mock("@/hooks/useActiveNetworks", () => ({
+ useActiveNetworks: () => mockUseActiveNetworks(),
+}));
+
+vi.mock("@/hooks/useSelectedNetwork", () => ({
+ useSelectedNetwork: () => mockUseSelectedNetwork(),
}));
import Landing from "./Landing";
-function makeProvider(overrides: Record = {}) {
+function makeProvider(overrides: Partial = {}): Provider {
return {
address: "0xabc123",
providerId: "1",
@@ -32,6 +47,7 @@ function makeProvider(overrides: Record = {}) {
isActive: true,
isApproved: true,
region: "US",
+ network: "mainnet",
metadata: {},
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
@@ -39,7 +55,7 @@ function makeProvider(overrides: Record = {}) {
};
}
-function setupMock(providers: ReturnType[] = [makeProvider()]) {
+function setupMock(providers: Provider[] = [makeProvider()]) {
mockUseProvidersList.mockReturnValue({
providers: {
providers,
@@ -53,18 +69,47 @@ function setupMock(providers: ReturnType[] = [makeProvider(
});
}
+function renderLanding() {
+ return render(
+
+
+ ,
+ );
+}
+
describe("Landing", () => {
+ beforeEach(() => {
+ mockUseActiveNetworks.mockReturnValue({ activeNetworks: ["mainnet"], loading: false, error: null });
+ mockUseSelectedNetwork.mockReturnValue(["mainnet", vi.fn()]);
+ setupMock();
+ });
+
it("renders string providerId in the table", () => {
setupMock([makeProvider({ providerId: "42" })]);
- render( );
+ renderLanding();
expect(screen.getByText("42")).toBeInTheDocument();
});
it("renders dash when providerId is null", () => {
setupMock([makeProvider({ providerId: null })]);
- render( );
+ renderLanding();
const row = screen.getByText("Test Provider").closest("tr")!;
const cells = row.querySelectorAll("td");
expect(cells[1].textContent).toBe("—");
});
+
+ it("shows config errors instead of leaving providers loading", () => {
+ mockUseActiveNetworks.mockReturnValue({
+ activeNetworks: [],
+ loading: false,
+ error: "Request failed: /api/config (HTTP 500)",
+ });
+ mockUseSelectedNetwork.mockReturnValue([null, vi.fn()]);
+ setupMock([]);
+
+ renderLanding();
+
+ expect(screen.getByText("Request failed: /api/config (HTTP 500)")).toBeInTheDocument();
+ expect(screen.queryByText("Loading providers…")).not.toBeInTheDocument();
+ });
});
diff --git a/apps/web/src/pages/Landing.tsx b/apps/web/src/pages/Landing.tsx
index f24bebaa..8af2d0e2 100644
--- a/apps/web/src/pages/Landing.tsx
+++ b/apps/web/src/pages/Landing.tsx
@@ -1,10 +1,11 @@
import { Activity, ExternalLink, LineChart } from "lucide-react";
-import { NetworkBadge } from "@/components/shared";
+import { NetworkBadge, NetworkSwitcher } from "@/components/shared";
import { NETWORK_LABEL } from "@/components/shared/Network/constants";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { useNetworkConfig } from "@/hooks/useNetworkConfig";
+import { useActiveNetworks } from "@/hooks/useActiveNetworks";
import { useProvidersList } from "@/hooks/useProvidersList";
+import { useSelectedNetwork } from "@/hooks/useSelectedNetwork";
import type { Network } from "@/types/config";
/**
@@ -80,7 +81,8 @@ const getConfig = (network: Network | null) => {
};
export default function Landing() {
- const { network } = useNetworkConfig();
+ const { activeNetworks, loading: configLoading, error: configError } = useActiveNetworks();
+ const [selectedNetwork, setSelectedNetwork] = useSelectedNetwork(activeNetworks);
const {
dashboardUrl,
dashboardUrlInvalid,
@@ -88,8 +90,16 @@ export default function Landing() {
approvedSpDashboardUrlInvalid,
logsUrl,
logsUrlInvalid,
- } = getConfig(network);
- const { providers: providersResponse, loading: providersLoading, error: providersError } = useProvidersList(0, 500);
+ } = getConfig(selectedNetwork);
+ // Pass null while config is loading to defer the fetch; once resolved, scope to the selected network.
+ const {
+ providers: providersResponse,
+ loading: providersLoading,
+ error: providersError,
+ } = useProvidersList(0, 500, selectedNetwork);
+
+ const providersDisplayError = configError ?? providersError;
+ const providersPending = configLoading || (!configError && selectedNetwork === null) || providersLoading;
return (
@@ -101,7 +111,7 @@ export default function Landing() {
-
+
@@ -130,10 +140,10 @@ export default function Landing() {
See the approval methodology ↗
- {approvedSpDashboardUrlInvalid && network && (
+ {approvedSpDashboardUrlInvalid && selectedNetwork && (
- Warning: APPROVED_SP_DASHBOARD_URL_{network.toUpperCase()} (or{" "}
- VITE_APPROVED_SP_DASHBOARD_URL_{network.toUpperCase()}) configured but invalid. Link
+ Warning: APPROVED_SP_DASHBOARD_URL_{selectedNetwork.toUpperCase()} (or{" "}
+ VITE_APPROVED_SP_DASHBOARD_URL_{selectedNetwork.toUpperCase()}) configured but invalid. Link
unavailable.
)}
@@ -151,7 +161,7 @@ export default function Landing() {
{/* Combined approved-SP dashboard CTA */}
- {approvedSpDashboardUrl && network && (
+ {approvedSpDashboardUrl && selectedNetwork && (
@@ -159,7 +169,7 @@ export default function Landing() {
Filecoin Onchain Cloud: approved SP performance
- Aggregated metrics across all SPs approved for FOC storage on {NETWORK_LABEL[network]}.
+ Aggregated metrics across all SPs approved for FOC storage on {NETWORK_LABEL[selectedNetwork]}.
@@ -176,7 +186,12 @@ export default function Landing() {
{/* Storage providers – metrics & logs */}
- Storage providers – metrics & logs
+
+ Storage providers – metrics & logs
+ {selectedNetwork !== null && (
+
+ )}
+
{(dashboardUrlInvalid || logsUrlInvalid) && (
@@ -198,10 +213,12 @@ export default function Landing() {
them.
)}
- {providersError && {providersError}
}
- {providersLoading && Loading providers…
}
- {!providersLoading &&
- !providersError &&
+ {providersDisplayError && {providersDisplayError}
}
+ {!providersDisplayError && providersPending && (
+ Loading providers…
+ )}
+ {!providersPending &&
+ !providersDisplayError &&
(() => {
const activeProviders = providersResponse.providers
.filter((p) => p.isActive)
@@ -279,11 +296,13 @@ export default function Landing() {
);
})()}
- {!providersLoading && !providersError && providersResponse.total > providersResponse.providers.length && (
-
- Showing first {providersResponse.providers.length} of {providersResponse.total} providers.
-
- )}
+ {!providersPending &&
+ !providersDisplayError &&
+ providersResponse.total > providersResponse.providers.length && (
+
+ Showing first {providersResponse.providers.length} of {providersResponse.total} providers.
+
+ )}
diff --git a/apps/web/src/types/config.ts b/apps/web/src/types/config.ts
index cb7692d8..77435e62 100644
--- a/apps/web/src/types/config.ts
+++ b/apps/web/src/types/config.ts
@@ -1,10 +1,15 @@
export type Network = "mainnet" | "calibration";
-export interface AppConfigResponse {
+export interface NetworkConfig {
network: Network;
- jobs: {
- dealsPerSpPerHour?: number;
- dataSetCreationsPerSpPerHour?: number;
- retrievalsPerSpPerHour?: number;
- };
+ dealsPerSpPerHour: number;
+ retrievalsPerSpPerHour: number;
+ dataSetCreationsPerSpPerHour: number;
+ pullChecksPerSpPerHour: number;
+ dataRetentionPollIntervalSeconds: number;
+ providersRefreshIntervalSeconds: number;
+}
+
+export interface AppConfigResponse {
+ networks: NetworkConfig[];
}
diff --git a/apps/web/src/types/providers.ts b/apps/web/src/types/providers.ts
index d2a50c18..075d2358 100644
--- a/apps/web/src/types/providers.ts
+++ b/apps/web/src/types/providers.ts
@@ -1,3 +1,5 @@
+import type { Network } from "./config";
+
/**
* Decoded provider info
*/
@@ -28,6 +30,7 @@ export interface PDPOffering {
*/
export interface Provider {
address: string;
+ network: Network;
providerId?: string | null;
name: string;
description: string;
diff --git a/apps/web/test/mocks/handlers/config.ts b/apps/web/test/mocks/handlers/config.ts
index a7b7a040..f49536de 100644
--- a/apps/web/test/mocks/handlers/config.ts
+++ b/apps/web/test/mocks/handlers/config.ts
@@ -2,7 +2,16 @@ import { HttpResponse, http } from "msw";
export const configHandler = http.get("/api/config", () => {
return HttpResponse.json({
- network: "mainnet",
- jobs: {},
+ networks: [
+ {
+ network: "mainnet",
+ dealsPerSpPerHour: 4,
+ retrievalsPerSpPerHour: 4,
+ dataSetCreationsPerSpPerHour: 0,
+ pullChecksPerSpPerHour: 0,
+ dataRetentionPollIntervalSeconds: 3600,
+ providersRefreshIntervalSeconds: 3600,
+ },
+ ],
});
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 52dfce55..4a47a82c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -226,6 +226,9 @@ importers:
'@radix-ui/react-switch':
specifier: ^1.2.6
version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-tabs':
+ specifier: ^1.1.13
+ version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.4(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.8.4))
@@ -1433,6 +1436,19 @@ packages:
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+ '@radix-ui/react-collection@1.1.7':
+ resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
@@ -1451,6 +1467,37 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-direction@1.1.1':
+ resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-id@1.1.1':
+ resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-presence@1.1.5':
+ resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-primitive@2.1.3':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
@@ -1464,6 +1511,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-roving-focus@1.1.11':
+ resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@@ -1495,6 +1555,28 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-tabs@1.1.13':
+ resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-use-callback-ref@1.1.1':
+ resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-use-controllable-state@1.2.2':
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
peerDependencies:
@@ -7550,6 +7632,18 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
react: 19.2.5
@@ -7562,6 +7656,29 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
+ '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)':
+ dependencies:
+ react: 19.2.5
+ optionalDependencies:
+ '@types/react': 19.2.14
+
+ '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ optionalDependencies:
+ '@types/react': 19.2.14
+
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
@@ -7571,6 +7688,23 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
'@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
@@ -7600,6 +7734,28 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
+ '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)':
+ dependencies:
+ react: 19.2.5
+ optionalDependencies:
+ '@types/react': 19.2.14
+
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)