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 ( + + ); +} 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 ( - + 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)