Quellcode durchsuchen

Merge pull request #2386 from pyth-network/cprussin/ui-14-add-publishers-feeds-and-price-components-from-the-pythtest

feat(insights): add publishers & price components from pythtest
Connor Prussin vor 9 Monaten
Ursprung
Commit
8731e699d1
39 geänderte Dateien mit 662 neuen und 393 gelöschten Zeilen
  1. 3 0
      apps/insights/src/app/publishers/[cluster]/[key]/error.ts
  2. 1 1
      apps/insights/src/app/publishers/[cluster]/[key]/layout.ts
  3. 4 0
      apps/insights/src/app/publishers/[cluster]/[key]/page.ts
  4. 4 0
      apps/insights/src/app/publishers/[cluster]/[key]/price-feeds/page.ts
  5. 0 3
      apps/insights/src/app/publishers/[key]/error.ts
  6. 0 4
      apps/insights/src/app/publishers/[key]/page.ts
  7. 0 4
      apps/insights/src/app/publishers/[key]/price-feeds/page.ts
  8. 50 17
      apps/insights/src/components/LivePrices/index.tsx
  9. 5 4
      apps/insights/src/components/PriceComponentDrawer/index.module.scss
  10. 34 7
      apps/insights/src/components/PriceComponentDrawer/index.tsx
  11. 0 8
      apps/insights/src/components/PriceComponentsCard/index.module.scss
  12. 8 12
      apps/insights/src/components/PriceComponentsCard/index.tsx
  13. 2 1
      apps/insights/src/components/PriceFeed/chart.tsx
  14. 19 3
      apps/insights/src/components/PriceFeed/layout.tsx
  15. 2 1
      apps/insights/src/components/PriceFeed/price-feed-select.tsx
  16. 74 57
      apps/insights/src/components/PriceFeed/publishers-card.tsx
  17. 19 9
      apps/insights/src/components/PriceFeed/publishers.tsx
  18. 2 0
      apps/insights/src/components/PriceFeed/reference-data.tsx
  19. 2 1
      apps/insights/src/components/PriceFeedChangePercent/index.tsx
  20. 4 1
      apps/insights/src/components/PriceFeeds/index.tsx
  21. 13 4
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  22. 5 20
      apps/insights/src/components/Publisher/layout.module.scss
  23. 146 136
      apps/insights/src/components/Publisher/layout.tsx
  24. 14 7
      apps/insights/src/components/Publisher/performance.tsx
  25. 5 1
      apps/insights/src/components/Publisher/price-feed-drawer-provider.tsx
  26. 6 4
      apps/insights/src/components/Publisher/price-feeds-card.tsx
  27. 12 3
      apps/insights/src/components/Publisher/price-feeds.tsx
  28. 4 0
      apps/insights/src/components/PublisherTag/index.module.scss
  29. 16 1
      apps/insights/src/components/PublisherTag/index.tsx
  30. 38 24
      apps/insights/src/components/Publishers/index.tsx
  31. 79 21
      apps/insights/src/components/Publishers/publishers-card.tsx
  32. 34 17
      apps/insights/src/components/Root/index.tsx
  33. 4 1
      apps/insights/src/components/Root/search-dialog.tsx
  34. 17 6
      apps/insights/src/hooks/use-live-price-data.tsx
  35. 3 1
      apps/insights/src/hooks/use-price-feeds.tsx
  36. 15 9
      apps/insights/src/services/clickhouse.ts
  37. 2 0
      apps/insights/src/services/hermes.ts
  38. 5 0
      apps/insights/src/services/pyth.ts
  39. 11 5
      packages/component-library/src/InfoBox/index.tsx

+ 3 - 0
apps/insights/src/app/publishers/[cluster]/[key]/error.ts

@@ -0,0 +1,3 @@
+"use client";
+
+export { Error as default } from "../../../../components/Error";

+ 1 - 1
apps/insights/src/app/publishers/[key]/layout.ts → apps/insights/src/app/publishers/[cluster]/[key]/layout.ts

@@ -1,6 +1,6 @@
 import type { Metadata } from "next";
 
-export { PublishersLayout as default } from "../../../components/Publisher/layout";
+export { PublishersLayout as default } from "../../../../components/Publisher/layout";
 
 export const metadata: Metadata = {
   title: "Publishers",

+ 4 - 0
apps/insights/src/app/publishers/[cluster]/[key]/page.ts

@@ -0,0 +1,4 @@
+export { Performance as default } from "../../../../components/Publisher/performance";
+
+export const dynamic = "error";
+export const revalidate = 3600;

+ 4 - 0
apps/insights/src/app/publishers/[cluster]/[key]/price-feeds/page.ts

@@ -0,0 +1,4 @@
+export { PriceFeeds as default } from "../../../../../components/Publisher/price-feeds";
+
+export const dynamic = "error";
+export const revalidate = 3600;

+ 0 - 3
apps/insights/src/app/publishers/[key]/error.ts

@@ -1,3 +0,0 @@
-"use client";
-
-export { Error as default } from "../../../components/Error";

+ 0 - 4
apps/insights/src/app/publishers/[key]/page.ts

@@ -1,4 +0,0 @@
-export { Performance as default } from "../../../components/Publisher/performance";
-
-export const dynamic = "error";
-export const revalidate = 3600;

+ 0 - 4
apps/insights/src/app/publishers/[key]/price-feeds/page.ts

@@ -1,4 +0,0 @@
-export { PriceFeeds as default } from "../../../../components/Publisher/price-feeds";
-
-export const dynamic = "error";
-export const revalidate = 3600;

+ 50 - 17
apps/insights/src/components/LivePrices/index.tsx

@@ -11,24 +11,32 @@ import {
   useLivePriceComponent,
   useLivePriceData,
 } from "../../hooks/use-live-price-data";
+import type { Cluster } from "../../services/pyth";
 
 export const SKELETON_WIDTH = 20;
 
 export const LivePrice = ({
-  feedKey,
   publisherKey,
+  ...props
 }: {
   feedKey: string;
   publisherKey?: string | undefined;
+  cluster: Cluster;
 }) =>
-  publisherKey ? (
-    <LiveComponentPrice feedKey={feedKey} publisherKey={publisherKey} />
+  publisherKey === undefined ? (
+    <LiveAggregatePrice {...props} />
   ) : (
-    <LiveAggregatePrice feedKey={feedKey} />
+    <LiveComponentPrice {...props} publisherKey={publisherKey} />
   );
 
-const LiveAggregatePrice = ({ feedKey }: { feedKey: string }) => {
-  const { prev, current } = useLivePriceData(feedKey);
+const LiveAggregatePrice = ({
+  feedKey,
+  cluster,
+}: {
+  feedKey: string;
+  cluster: Cluster;
+}) => {
+  const { prev, current } = useLivePriceData(cluster, feedKey);
   return (
     <Price current={current?.aggregate.price} prev={prev?.aggregate.price} />
   );
@@ -37,11 +45,17 @@ const LiveAggregatePrice = ({ feedKey }: { feedKey: string }) => {
 const LiveComponentPrice = ({
   feedKey,
   publisherKey,
+  cluster,
 }: {
   feedKey: string;
   publisherKey: string;
+  cluster: Cluster;
 }) => {
-  const { prev, current } = useLivePriceComponent(feedKey, publisherKey);
+  const { prev, current } = useLivePriceComponent(
+    cluster,
+    feedKey,
+    publisherKey,
+  );
   return <Price current={current?.latest.price} prev={prev?.latest.price} />;
 };
 
@@ -67,31 +81,40 @@ const Price = ({
 };
 
 export const LiveConfidence = ({
-  feedKey,
   publisherKey,
+  ...props
 }: {
   feedKey: string;
   publisherKey?: string | undefined;
+  cluster: Cluster;
 }) =>
   publisherKey === undefined ? (
-    <LiveAggregateConfidence feedKey={feedKey} />
+    <LiveAggregateConfidence {...props} />
   ) : (
-    <LiveComponentConfidence feedKey={feedKey} publisherKey={publisherKey} />
+    <LiveComponentConfidence {...props} publisherKey={publisherKey} />
   );
 
-const LiveAggregateConfidence = ({ feedKey }: { feedKey: string }) => {
-  const { current } = useLivePriceData(feedKey);
+const LiveAggregateConfidence = ({
+  feedKey,
+  cluster,
+}: {
+  feedKey: string;
+  cluster: Cluster;
+}) => {
+  const { current } = useLivePriceData(cluster, feedKey);
   return <Confidence confidence={current?.aggregate.confidence} />;
 };
 
 const LiveComponentConfidence = ({
   feedKey,
   publisherKey,
+  cluster,
 }: {
   feedKey: string;
   publisherKey: string;
+  cluster: Cluster;
 }) => {
-  const { current } = useLivePriceComponent(feedKey, publisherKey);
+  const { current } = useLivePriceComponent(cluster, feedKey, publisherKey);
   return <Confidence confidence={current?.latest.confidence} />;
 };
 
@@ -110,8 +133,14 @@ const Confidence = ({ confidence }: { confidence?: number | undefined }) => {
   );
 };
 
-export const LiveLastUpdated = ({ feedKey }: { feedKey: string }) => {
-  const { current } = useLivePriceData(feedKey);
+export const LiveLastUpdated = ({
+  feedKey,
+  cluster,
+}: {
+  feedKey: string;
+  cluster: Cluster;
+}) => {
+  const { current } = useLivePriceData(cluster, feedKey);
   const formatterWithDate = useDateFormatter({
     dateStyle: "short",
     timeStyle: "medium",
@@ -137,14 +166,16 @@ type LiveValueProps<T extends keyof PriceData> = {
   field: T;
   feedKey: string;
   defaultValue?: ReactNode | undefined;
+  cluster: Cluster;
 };
 
 export const LiveValue = <T extends keyof PriceData>({
   feedKey,
   field,
   defaultValue,
+  cluster,
 }: LiveValueProps<T>) => {
-  const { current } = useLivePriceData(feedKey);
+  const { current } = useLivePriceData(cluster, feedKey);
 
   return current !== undefined || defaultValue !== undefined ? (
     (current?.[field]?.toString() ?? defaultValue)
@@ -158,6 +189,7 @@ type LiveComponentValueProps<T extends keyof PriceComponent["latest"]> = {
   feedKey: string;
   publisherKey: string;
   defaultValue?: ReactNode | undefined;
+  cluster: Cluster;
 };
 
 export const LiveComponentValue = <T extends keyof PriceComponent["latest"]>({
@@ -165,8 +197,9 @@ export const LiveComponentValue = <T extends keyof PriceComponent["latest"]>({
   field,
   publisherKey,
   defaultValue,
+  cluster,
 }: LiveComponentValueProps<T>) => {
-  const { current } = useLivePriceComponent(feedKey, publisherKey);
+  const { current } = useLivePriceComponent(cluster, feedKey, publisherKey);
 
   return current !== undefined || defaultValue !== undefined ? (
     (current?.latest[field].toString() ?? defaultValue)

+ 5 - 4
apps/insights/src/components/PriceComponentDrawer/index.module.scss

@@ -1,16 +1,17 @@
 @use "@pythnetwork/component-library/theme";
 
 .priceComponentDrawer {
-  display: grid;
-  grid-template-rows: repeat(2, max-content);
-  grid-template-columns: 100%;
-  gap: theme.spacing(10);
+  .testFeedMessage {
+    grid-column: span 2 / span 2;
+    margin-bottom: theme.spacing(10);
+  }
 
   .stats {
     display: grid;
     grid-template-columns: repeat(3, 1fr);
     grid-template-rows: repeat(2, 1fr);
     gap: theme.spacing(4);
+    margin-bottom: theme.spacing(10);
   }
 
   .spinner {

+ 34 - 7
apps/insights/src/components/PriceComponentDrawer/index.tsx

@@ -1,6 +1,8 @@
+import { Flask } from "@phosphor-icons/react/dist/ssr/Flask";
 import { Button } from "@pythnetwork/component-library/Button";
 import { Card } from "@pythnetwork/component-library/Card";
 import { Drawer } from "@pythnetwork/component-library/Drawer";
+import { InfoBox } from "@pythnetwork/component-library/InfoBox";
 import { Select } from "@pythnetwork/component-library/Select";
 import { Spinner } from "@pythnetwork/component-library/Spinner";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
@@ -45,28 +47,32 @@ type Props = {
   headingExtra?: ReactNode | undefined;
   publisherKey: string;
   symbol: string;
+  displaySymbol: string;
   feedKey: string;
   score: number | undefined;
   rank: number | undefined;
   status: Status;
-  navigateButtonText: string;
+  identifiesPublisher?: boolean | undefined;
   navigateHref: string;
   firstEvaluation: Date;
+  cluster: Cluster;
 };
 
 export const PriceComponentDrawer = ({
   publisherKey,
   onClose,
   symbol,
+  displaySymbol,
   feedKey,
   score,
   rank,
   title,
   status,
   headingExtra,
-  navigateButtonText,
   navigateHref,
   firstEvaluation,
+  cluster,
+  identifiesPublisher,
 }: Props) => {
   const goToPriceFeedPageOnClose = useRef<boolean>(false);
   const [isFeedDrawerOpen, setIsFeedDrawerOpen] = useState(true);
@@ -93,7 +99,7 @@ export const PriceComponentDrawer = ({
   const { selectedPeriod, setSelectedPeriod, evaluationPeriods } =
     useEvaluationPeriods(firstEvaluation);
   const scoreHistoryState = useData(
-    [Cluster.Pythnet, publisherKey, symbol, selectedPeriod],
+    [cluster, publisherKey, symbol, selectedPeriod],
     getScoreHistory,
   );
 
@@ -108,7 +114,7 @@ export const PriceComponentDrawer = ({
           <StatusComponent status={status} />
           <RouterProvider navigate={handleOpenFeed}>
             <Button size="sm" variant="outline" href={navigateHref}>
-              {navigateButtonText}
+              Open {identifiesPublisher ? "Publisher" : "Feed"}
             </Button>
           </RouterProvider>
         </>
@@ -116,26 +122,46 @@ export const PriceComponentDrawer = ({
       isOpen={isFeedDrawerOpen}
       bodyClassName={styles.priceComponentDrawer}
     >
+      {cluster === Cluster.PythtestConformance && (
+        <InfoBox
+          icon={<Flask />}
+          header={`This publisher is in test`}
+          className={styles.testFeedMessage}
+        >
+          This is a test publisher. Its prices are not included in the Pyth
+          aggregate price for {displaySymbol}.
+        </InfoBox>
+      )}
       <div className={styles.stats}>
         <StatCard
           nonInteractive
           header="Aggregate Price"
           small
-          stat={<LivePrice feedKey={feedKey} />}
+          stat={<LivePrice feedKey={feedKey} cluster={cluster} />}
         />
         <StatCard
           nonInteractive
           header="Publisher Price"
           variant="primary"
           small
-          stat={<LivePrice feedKey={feedKey} publisherKey={publisherKey} />}
+          stat={
+            <LivePrice
+              feedKey={feedKey}
+              publisherKey={publisherKey}
+              cluster={cluster}
+            />
+          }
         />
         <StatCard
           nonInteractive
           header="Publisher Confidence"
           small
           stat={
-            <LiveConfidence feedKey={feedKey} publisherKey={publisherKey} />
+            <LiveConfidence
+              feedKey={feedKey}
+              publisherKey={publisherKey}
+              cluster={cluster}
+            />
           }
         />
         <StatCard
@@ -147,6 +173,7 @@ export const PriceComponentDrawer = ({
               feedKey={feedKey}
               publisherKey={publisherKey}
               field="publishSlot"
+              cluster={cluster}
             />
           }
         />

+ 0 - 8
apps/insights/src/components/PriceComponentsCard/index.module.scss

@@ -1,8 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.componentName {
-  display: flex;
-  flex-flow: row nowrap;
-  align-items: center;
-  gap: theme.spacing(6);
-}

+ 8 - 12
apps/insights/src/components/PriceComponentsCard/index.tsx

@@ -17,7 +17,6 @@ import { useQueryState, parseAsStringEnum, parseAsBoolean } from "nuqs";
 import { type ReactNode, Suspense, useMemo, useCallback } from "react";
 import { useFilter, useCollator } from "react-aria";
 
-import styles from "./index.module.scss";
 import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
 import { Cluster } from "../../services/pyth";
 import {
@@ -45,12 +44,12 @@ type Props<T extends PriceComponent> = {
   label: string;
   searchPlaceholder: string;
   onPriceComponentAction: (component: T) => void;
+  toolbarExtra?: ReactNode;
 };
 
 type PriceComponent = {
   id: string;
   score: number | undefined;
-  symbol: string;
   uptimeScore: number | undefined;
   deviationScore: number | undefined;
   stalledScore: number | undefined;
@@ -172,16 +171,7 @@ export const ResolvedPriceComponentsCard = <T extends PriceComponent>({
       paginatedItems.map((component) => ({
         id: component.id,
         data: {
-          name: (
-            <div className={styles.componentName}>
-              {component.name}
-              {component.cluster === Cluster.PythtestConformance && (
-                <Badge variant="muted" style="filled" size="xs">
-                  test
-                </Badge>
-              )}
-            </div>
-          ),
+          name: component.name,
           ...(showQuality
             ? {
                 score: component.score !== undefined && (
@@ -212,18 +202,21 @@ export const ResolvedPriceComponentsCard = <T extends PriceComponent>({
                     feedKey={component.feedKey}
                     publisherKey={component.publisherKey}
                     field="publishSlot"
+                    cluster={component.cluster}
                   />
                 ),
                 price: (
                   <LivePrice
                     feedKey={component.feedKey}
                     publisherKey={component.publisherKey}
+                    cluster={component.cluster}
                   />
                 ),
                 confidence: (
                   <LiveConfidence
                     feedKey={component.feedKey}
                     publisherKey={component.publisherKey}
+                    cluster={component.cluster}
                   />
                 ),
               }),
@@ -285,6 +278,7 @@ type PriceComponentsCardProps<T extends PriceComponent> = Pick<
   | "nameLoadingSkeleton"
   | "label"
   | "searchPlaceholder"
+  | "toolbarExtra"
 > &
   (
     | { isLoading: true }
@@ -315,6 +309,7 @@ export const PriceComponentsCardContents = <T extends PriceComponent>({
   nameLoadingSkeleton,
   label,
   searchPlaceholder,
+  toolbarExtra,
   ...props
 }: PriceComponentsCardProps<T>) => {
   const collator = useCollator();
@@ -333,6 +328,7 @@ export const PriceComponentsCardContents = <T extends PriceComponent>({
       }
       toolbar={
         <>
+          {toolbarExtra}
           <Select<StatusName | "">
             label="Status"
             size="sm"

+ 2 - 1
apps/insights/src/components/PriceFeed/chart.tsx

@@ -15,6 +15,7 @@ import { z } from "zod";
 
 import theme from "./theme.module.scss";
 import { useLivePriceData } from "../../hooks/use-live-price-data";
+import { Cluster } from "../../services/pyth";
 
 type Props = {
   symbol: string;
@@ -38,7 +39,7 @@ const useChart = (symbol: string, feedId: string) => {
 
 const useChartElem = (symbol: string, feedId: string) => {
   const logger = useLogger();
-  const { current } = useLivePriceData(feedId);
+  const { current } = useLivePriceData(Cluster.Pythnet, feedId);
   const chartContainerRef = useRef<HTMLDivElement | null>(null);
   const chartRef = useRef<ChartRefContents | undefined>(undefined);
   const earliestDateRef = useRef<bigint | undefined>(undefined);

+ 19 - 3
apps/insights/src/components/PriceFeed/layout.tsx

@@ -111,11 +111,21 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
           <StatCard
             variant="primary"
             header="Aggregated Price"
-            stat={<LivePrice feedKey={feed.product.price_account} />}
+            stat={
+              <LivePrice
+                feedKey={feed.product.price_account}
+                cluster={Cluster.Pythnet}
+              />
+            }
           />
           <StatCard
             header="Confidence"
-            stat={<LiveConfidence feedKey={feed.product.price_account} />}
+            stat={
+              <LiveConfidence
+                feedKey={feed.product.price_account}
+                cluster={Cluster.Pythnet}
+              />
+            }
             corner={
               <AlertTrigger>
                 <Button
@@ -159,7 +169,12 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
           />
           <StatCard
             header="Last Updated"
-            stat={<LiveLastUpdated feedKey={feed.product.price_account} />}
+            stat={
+              <LiveLastUpdated
+                feedKey={feed.product.price_account}
+                cluster={Cluster.Pythnet}
+              />
+            }
           />
         </section>
       </section>
@@ -179,6 +194,7 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
                       feedKey={feed.product.price_account}
                       field="numComponentPrices"
                       defaultValue={feed.price.numComponentPrices}
+                      cluster={Cluster.Pythnet}
                     />
                   </Badge>
                 </div>

+ 2 - 1
apps/insights/src/components/PriceFeed/price-feed-select.tsx

@@ -20,6 +20,7 @@ import { useCollator, useFilter } from "react-aria";
 
 import styles from "./price-feed-select.module.scss";
 import { usePriceFeeds } from "../../hooks/use-price-feeds";
+import { Cluster } from "../../services/pyth";
 import { AssetClassTag } from "../AssetClassTag";
 import { PriceFeedTag } from "../PriceFeedTag";
 
@@ -42,7 +43,7 @@ export const PriceFeedSelect = ({ children }: Props) => {
               ([, { displaySymbol, assetClass, key }]) =>
                 filter.contains(displaySymbol, search) ||
                 filter.contains(assetClass, search) ||
-                filter.contains(key, search),
+                filter.contains(key[Cluster.Pythnet], search),
             ),
     [feeds, search, filter],
   );

+ 74 - 57
apps/insights/src/components/PriceFeed/publishers-card.tsx

@@ -1,18 +1,16 @@
 "use client";
 
 import { useLogger } from "@pythnetwork/app-logger";
-import {
-  useQueryState,
-  parseAsString, // , parseAsBoolean
-} from "nuqs";
+import { Switch } from "@pythnetwork/component-library/Switch";
+import { useQueryState, parseAsString, parseAsBoolean } from "nuqs";
 import { type ComponentProps, Suspense, useCallback, useMemo } from "react";
 
+import { Cluster, ClusterToName } from "../../services/pyth";
 import { PriceComponentDrawer } from "../PriceComponentDrawer";
 import {
   PriceComponentsCardContents,
   ResolvedPriceComponentsCard,
 } from "../PriceComponentsCard";
-// import { Cluster } from "../../services/pyth";
 
 type Publisher = ComponentProps<
   typeof ResolvedPriceComponentsCard
@@ -26,80 +24,98 @@ type Props = Omit<
   "onPriceComponentAction" | "priceComponents"
 > & {
   priceComponents: Publisher[];
+  symbol: string;
+  displaySymbol: string;
 };
 
-export const PublishersCard = ({ priceComponents, ...props }: Props) => (
+export const PublishersCard = ({
+  priceComponents,
+  symbol,
+  displaySymbol,
+  ...props
+}: Props) => (
   <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
-    <ResolvedPublishersCard priceComponents={priceComponents} {...props} />
+    <ResolvedPublishersCard
+      priceComponents={priceComponents}
+      symbol={symbol}
+      displaySymbol={displaySymbol}
+      {...props}
+    />
   </Suspense>
 );
 
-const ResolvedPublishersCard = ({ priceComponents, ...props }: Props) => {
-  // const logger = useLogger();
+const ResolvedPublishersCard = ({
+  priceComponents,
+  symbol,
+  displaySymbol,
+  ...props
+}: Props) => {
+  const logger = useLogger();
   const { handleClose, selectedPublisher, updateSelectedPublisherKey } =
     usePublisherDrawer(priceComponents);
   const onPriceComponentAction = useCallback(
-    ({ publisherKey }: Publisher) => {
-      updateSelectedPublisherKey(publisherKey);
+    ({ publisherKey, cluster }: Publisher) => {
+      updateSelectedPublisherKey(
+        [ClusterToName[cluster], publisherKey].join(":"),
+      );
     },
     [updateSelectedPublisherKey],
   );
-  // const [includeTestFeeds, setIncludeTestFeeds] = useQueryState(
-  //   "includeTestFeeds",
-  //   parseAsBoolean.withDefault(false),
-  // );
-  // const componentsFilteredByCluster = useMemo(
-  //   () =>
-  //     includeTestFeeds
-  //       ? priceComponents
-  //       : priceComponents.filter(
-  //           (component) => component.cluster === Cluster.Pythnet,
-  //         ),
-  //   [includeTestFeeds, priceComponents],
-  // );
-  // const updateIncludeTestFeeds = useCallback(
-  //   (newValue: boolean) => {
-  //     setIncludeTestFeeds(newValue).catch((error: unknown) => {
-  //       logger.error(
-  //         "Failed to update include test components query param",
-  //         error,
-  //       );
-  //     });
-  //   },
-  //   [setIncludeTestFeeds, logger],
-  // );
-  //         <Switch
-  //           {...(props.isLoading
-  //             ? { isLoading: true }
-  //             : {
-  //                 isSelected: props.includeTestFeeds,
-  //                 onChange: props.onIncludeTestFeedsChange,
-  //               })}
-  //         >
-  //           Show test feeds
-  //         </Switch>
+  const [includeTestFeeds, setIncludeTestFeeds] = useQueryState(
+    "includeTestFeeds",
+    parseAsBoolean.withDefault(false),
+  );
+  const componentsFilteredByCluster = useMemo(
+    () =>
+      includeTestFeeds
+        ? priceComponents
+        : priceComponents.filter(
+            (component) => component.cluster === Cluster.Pythnet,
+          ),
+    [includeTestFeeds, priceComponents],
+  );
+  const updateIncludeTestFeeds = useCallback(
+    (newValue: boolean) => {
+      setIncludeTestFeeds(newValue).catch((error: unknown) => {
+        logger.error(
+          "Failed to update include test components query param",
+          error,
+        );
+      });
+    },
+    [setIncludeTestFeeds, logger],
+  );
 
   return (
     <>
       <ResolvedPriceComponentsCard
         onPriceComponentAction={onPriceComponentAction}
-        // priceComponents={componentsFilteredByCluster}
-        priceComponents={priceComponents}
+        priceComponents={componentsFilteredByCluster}
+        toolbarExtra={
+          <Switch
+            isSelected={includeTestFeeds}
+            onChange={updateIncludeTestFeeds}
+          >
+            Include test publishers
+          </Switch>
+        }
         {...props}
       />
       {selectedPublisher && (
         <PriceComponentDrawer
           publisherKey={selectedPublisher.publisherKey}
           onClose={handleClose}
-          symbol={selectedPublisher.symbol}
+          symbol={symbol}
+          displaySymbol={displaySymbol}
           feedKey={selectedPublisher.feedKey}
           rank={selectedPublisher.rank}
           score={selectedPublisher.score}
           status={selectedPublisher.status}
           title={selectedPublisher.name}
+          cluster={selectedPublisher.cluster}
           firstEvaluation={selectedPublisher.firstEvaluation ?? new Date()}
-          navigateButtonText="Open Publisher"
-          navigateHref={`/publishers/${selectedPublisher.publisherKey}`}
+          navigateHref={`/publishers/${ClusterToName[selectedPublisher.cluster]}/${selectedPublisher.publisherKey}`}
+          identifiesPublisher
         />
       )}
     </>
@@ -122,13 +138,14 @@ const usePublisherDrawer = (publishers: Publisher[]) => {
     },
     [setSelectedPublisher, logger],
   );
-  const selectedPublisher = useMemo(
-    () =>
-      publishers.find(
-        (publisher) => publisher.publisherKey === selectedPublisherKey,
-      ),
-    [selectedPublisherKey, publishers],
-  );
+  const selectedPublisher = useMemo(() => {
+    const [cluster, publisherKey] = selectedPublisherKey.split(":");
+    return publishers.find(
+      (publisher) =>
+        publisher.publisherKey === publisherKey &&
+        ClusterToName[publisher.cluster] === cluster,
+    );
+  }, [selectedPublisherKey, publishers]);
   const handleClose = useCallback(() => {
     updateSelectedPublisherKey("");
   }, [updateSelectedPublisherKey]);

+ 19 - 9
apps/insights/src/components/PriceFeed/publishers.tsx

@@ -23,16 +23,21 @@ export const Publishers = async ({ params }: Props) => {
   const { slug } = await params;
   const symbol = decodeURIComponent(slug);
   const [
-    feeds,
-    pythnetPublishers, // , pythtestConformancePublishers
+    pythnetFeeds,
+    pythtestConformanceFeeds,
+    pythnetPublishers,
+    pythtestConformancePublishers,
   ] = await Promise.all([
     getFeeds(Cluster.Pythnet),
+    getFeeds(Cluster.PythtestConformance),
     getPublishers(Cluster.Pythnet, symbol),
-    // getPublishers(Cluster.PythtestConformance, symbol),
+    getPublishers(Cluster.PythtestConformance, symbol),
   ]);
-  const feed = feeds.find((feed) => feed.symbol === symbol);
-  // const publishers = [...pythnetPublishers, ...pythtestConformancePublishers];
-  const publishers = [...pythnetPublishers];
+  const feed = pythnetFeeds.find((feed) => feed.symbol === symbol);
+  const testFeed = pythtestConformanceFeeds.find(
+    (feed) => feed.symbol === symbol,
+  );
+  const publishers = [...pythnetPublishers, ...pythtestConformancePublishers];
   const metricsTime = pythnetPublishers.find(
     (publisher) => publisher.ranking !== undefined,
   )?.ranking?.time;
@@ -45,10 +50,15 @@ export const Publishers = async ({ params }: Props) => {
       searchPlaceholder="Publisher key or name"
       metricsTime={metricsTime}
       nameLoadingSkeleton={<PublisherTag isLoading />}
+      symbol={symbol}
+      displaySymbol={feed.product.display_symbol}
       priceComponents={publishers.map(
         ({ ranking, publisher, status, cluster, knownPublisher }) => ({
-          id: `${publisher}-${ClusterToName[Cluster.Pythnet]}`,
-          feedKey: feed.product.price_account,
+          id: `${publisher}-${ClusterToName[cluster]}`,
+          feedKey:
+            cluster === Cluster.Pythnet
+              ? feed.product.price_account
+              : (testFeed?.product.price_account ?? ""),
           score: ranking?.final_score,
           uptimeScore: ranking?.uptime_score,
           deviationScore: ranking?.deviation_score,
@@ -56,12 +66,12 @@ export const Publishers = async ({ params }: Props) => {
           cluster,
           status,
           publisherKey: publisher,
-          symbol,
           rank: ranking?.final_rank,
           firstEvaluation: ranking?.first_ranking_time,
           name: (
             <PublisherTag
               publisherKey={publisher}
+              cluster={cluster}
               {...(knownPublisher && {
                 name: knownPublisher.name,
                 icon: <PublisherIcon knownPublisher={knownPublisher} />,

+ 2 - 0
apps/insights/src/components/PriceFeed/reference-data.tsx

@@ -5,6 +5,7 @@ import { useMemo } from "react";
 import { useCollator } from "react-aria";
 
 import styles from "./reference-data.module.scss";
+import { Cluster } from "../../services/pyth";
 import { AssetClassTag } from "../AssetClassTag";
 import { LiveValue } from "../LivePrices";
 
@@ -74,6 +75,7 @@ export const ReferenceData = ({ feed }: Props) => {
                   feedKey={feed.feedKey}
                   field={value}
                   defaultValue={feed[value]}
+                  cluster={Cluster.Pythnet}
                 />
               </span>,
             ] as const,

+ 2 - 1
apps/insights/src/components/PriceFeedChangePercent/index.tsx

@@ -5,6 +5,7 @@ import { z } from "zod";
 
 import { StateType, useData } from "../../hooks/use-data";
 import { useLivePriceData } from "../../hooks/use-live-price-data";
+import { Cluster } from "../../services/pyth";
 import { ChangePercent } from "../ChangePercent";
 
 const ONE_SECOND_IN_MS = 1000;
@@ -105,7 +106,7 @@ const PriceFeedChangePercentLoaded = ({
   priorPrice,
   feedKey,
 }: PriceFeedChangePercentLoadedProps) => {
-  const { current } = useLivePriceData(feedKey);
+  const { current } = useLivePriceData(Cluster.Pythnet, feedKey);
 
   return current === undefined ? (
     <ChangePercent className={className} isLoading />

+ 4 - 1
apps/insights/src/components/PriceFeeds/index.tsx

@@ -176,7 +176,10 @@ const FeaturedFeedsCard = <T extends ElementType>({
             <PriceFeedTag symbol={feed.symbol} />
             {showPrices && (
               <div className={styles.prices}>
-                <LivePrice feedKey={feed.product.price_account} />
+                <LivePrice
+                  feedKey={feed.product.price_account}
+                  cluster={Cluster.Pythnet}
+                />
                 <PriceFeedChangePercent
                   className={styles.changePercent}
                   feedKey={feed.product.price_account}

+ 13 - 4
apps/insights/src/components/PriceFeeds/price-feeds-card.tsx

@@ -18,6 +18,7 @@ import { useFilter, useCollator } from "react-aria";
 
 import { usePriceFeeds } from "../../hooks/use-price-feeds";
 import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
+import { Cluster } from "../../services/pyth";
 import { AssetClassTag } from "../AssetClassTag";
 import { FeedKey } from "../FeedKey";
 import {
@@ -65,7 +66,7 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
             ...feed,
             assetClass: contextFeed.assetClass,
             displaySymbol: contextFeed.displaySymbol,
-            key: contextFeed.key,
+            key: contextFeed.key[Cluster.Pythnet],
           };
         } else {
           throw new NoSuchFeedError(feed.symbol);
@@ -123,17 +124,25 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
         href: `/price-feeds/${encodeURIComponent(symbol)}`,
         data: {
           exponent: (
-            <LiveValue field="exponent" feedKey={key} defaultValue={exponent} />
+            <LiveValue
+              field="exponent"
+              feedKey={key}
+              defaultValue={exponent}
+              cluster={Cluster.Pythnet}
+            />
           ),
           numPublishers: (
             <LiveValue
               field="numQuoters"
               feedKey={key}
               defaultValue={numQuoters}
+              cluster={Cluster.Pythnet}
             />
           ),
-          price: <LivePrice feedKey={key} />,
-          confidenceInterval: <LiveConfidence feedKey={key} />,
+          price: <LivePrice feedKey={key} cluster={Cluster.Pythnet} />,
+          confidenceInterval: (
+            <LiveConfidence feedKey={key} cluster={Cluster.Pythnet} />
+          ),
           priceFeedName: <PriceFeedTag compact symbol={symbol} />,
           assetClass: <AssetClassTag symbol={symbol} />,
           priceFeedId: <FeedKey size="xs" variant="ghost" feedKey={key} />,

+ 5 - 20
apps/insights/src/components/Publisher/layout.module.scss

@@ -9,16 +9,17 @@
     flex-flow: column nowrap;
     gap: theme.spacing(8);
 
-    .headerRow,
-    .rightGroup,
+    .breadcrumbRow,
     .stats {
       display: flex;
       flex-flow: row nowrap;
+      align-items: center;
     }
 
-    .headerRow,
-    .rightGroup {
+    .breadcrumbRow {
       align-items: center;
+      justify-content: space-between;
+      margin-bottom: -#{theme.spacing(2)};
     }
 
     .stats {
@@ -30,10 +31,6 @@
         width: 0;
       }
 
-      .averageScoreChart svg {
-        cursor: pointer;
-      }
-
       .activeDate {
         color: theme.color("muted");
       }
@@ -48,18 +45,6 @@
         color: theme.color("states", "error", "base");
       }
     }
-
-    .headerRow {
-      justify-content: space-between;
-    }
-
-    .rightGroup {
-      gap: theme.spacing(2);
-    }
-
-    .breadcrumbs {
-      margin-bottom: -#{theme.spacing(2)};
-    }
   }
 
   .priceFeedsTabLabel {

+ 146 - 136
apps/insights/src/components/Publisher/layout.tsx

@@ -23,7 +23,7 @@ import {
   getPublishers,
 } from "../../services/clickhouse";
 import { getPublisherCaps } from "../../services/hermes";
-import { Cluster } from "../../services/pyth";
+import { Cluster, ClusterToName, parseCluster } from "../../services/pyth";
 import { getPublisherPoolData } from "../../services/staking";
 import { ChangePercent } from "../ChangePercent";
 import { ChangeValue } from "../ChangeValue";
@@ -49,12 +49,19 @@ import { TokenIcon } from "../TokenIcon";
 type Props = {
   children: ReactNode;
   params: Promise<{
+    cluster: string;
     key: string;
   }>;
 };
 
 export const PublishersLayout = async ({ children, params }: Props) => {
-  const { key } = await params;
+  const { cluster, key } = await params;
+  const parsedCluster = parseCluster(cluster);
+
+  if (parsedCluster === undefined) {
+    notFound();
+  }
+
   const [
     rankingHistory,
     averageScoreHistory,
@@ -62,11 +69,11 @@ export const PublishersLayout = async ({ children, params }: Props) => {
     priceFeeds,
     publishers,
   ] = await Promise.all([
-    getPublisherRankingHistory(key),
-    getPublisherAverageScoreHistory(key),
+    getPublisherRankingHistory(parsedCluster, key),
+    getPublisherAverageScoreHistory(parsedCluster, key),
     getOisStats(key),
-    getPriceFeeds(Cluster.Pythnet, key),
-    getPublishers(),
+    getPriceFeeds(parsedCluster, key),
+    getPublishers(parsedCluster),
   ]);
 
   const currentRanking = rankingHistory.at(-1);
@@ -79,6 +86,7 @@ export const PublishersLayout = async ({ children, params }: Props) => {
 
   return publisher && currentRanking && currentAverageScore ? (
     <PriceFeedDrawerProvider
+      cluster={parsedCluster}
       publisherKey={key}
       priceFeeds={priceFeeds.map(({ feed, ranking, status }) => ({
         symbol: feed.symbol,
@@ -94,7 +102,7 @@ export const PublishersLayout = async ({ children, params }: Props) => {
     >
       <div className={styles.publisherLayout}>
         <section className={styles.header}>
-          <div className={styles.headerRow}>
+          <div className={styles.breadcrumbRow}>
             <Breadcrumbs
               className={styles.breadcrumbs ?? ""}
               label="Breadcrumbs"
@@ -105,15 +113,14 @@ export const PublishersLayout = async ({ children, params }: Props) => {
               ]}
             />
           </div>
-          <div className={styles.headerRow}>
-            <PublisherTag
-              publisherKey={key}
-              {...(knownPublisher && {
-                name: knownPublisher.name,
-                icon: <PublisherIcon knownPublisher={knownPublisher} />,
-              })}
-            />
-          </div>
+          <PublisherTag
+            cluster={parsedCluster}
+            publisherKey={key}
+            {...(knownPublisher && {
+              name: knownPublisher.name,
+              icon: <PublisherIcon knownPublisher={knownPublisher} />,
+            })}
+          />
           <section className={styles.stats}>
             <ChartCard
               variant="primary"
@@ -152,7 +159,6 @@ export const PublishersLayout = async ({ children, params }: Props) => {
             />
             <ChartCard
               header="Average Score"
-              chartClassName={styles.averageScoreChart}
               corner={<ExplainAverage />}
               data={averageScoreHistory.map(({ time, averageScore }) => ({
                 x: time,
@@ -199,7 +205,7 @@ export const PublishersLayout = async ({ children, params }: Props) => {
               }
               stat1={
                 <Link
-                  href={`/publishers/${key}/price-feeds?status=Active`}
+                  href={`/publishers/${ClusterToName[parsedCluster]}/${key}/price-feeds?status=Active`}
                   invert
                 >
                   {publisher.activeFeeds}
@@ -207,7 +213,7 @@ export const PublishersLayout = async ({ children, params }: Props) => {
               }
               stat2={
                 <Link
-                  href={`/publishers/${key}/price-feeds?status=Inactive`}
+                  href={`/publishers/${ClusterToName[parsedCluster]}/${key}/price-feeds?status=Inactive`}
                   invert
                 >
                   {publisher.inactiveFeeds}
@@ -238,136 +244,140 @@ export const PublishersLayout = async ({ children, params }: Props) => {
                 label="Active Feeds"
               />
             </StatCard>
-            <DrawerTrigger>
-              <StatCard
-                header="OIS Pool Allocation"
-                stat={
-                  <span
-                    className={styles.oisAllocation}
-                    data-is-overallocated={
-                      Number(oisStats.poolUtilization) > oisStats.maxPoolSize
-                        ? ""
-                        : undefined
-                    }
-                  >
-                    <FormattedNumber
-                      maximumFractionDigits={2}
-                      value={
-                        (100 * Number(oisStats.poolUtilization)) /
-                        oisStats.maxPoolSize
+            {parsedCluster === Cluster.Pythnet && (
+              <DrawerTrigger>
+                <StatCard
+                  header="OIS Pool Allocation"
+                  stat={
+                    <span
+                      className={styles.oisAllocation}
+                      data-is-overallocated={
+                        Number(oisStats.poolUtilization) > oisStats.maxPoolSize
+                          ? ""
+                          : undefined
                       }
-                    />
-                    %
-                  </span>
-                }
-                corner={<ArrowsOutSimple />}
-              >
-                <Meter
-                  value={Number(oisStats.poolUtilization)}
-                  maxValue={oisStats.maxPoolSize}
-                  label="OIS Pool"
-                  startLabel={
-                    <span className={styles.tokens}>
-                      <TokenIcon />
-                      <span>
-                        <FormattedTokens tokens={oisStats.poolUtilization} />
-                      </span>
-                    </span>
-                  }
-                  endLabel={
-                    <span className={styles.tokens}>
-                      <TokenIcon />
-                      <span>
-                        <FormattedTokens
-                          tokens={BigInt(oisStats.maxPoolSize)}
-                        />
-                      </span>
+                    >
+                      <FormattedNumber
+                        maximumFractionDigits={2}
+                        value={
+                          (100 * Number(oisStats.poolUtilization)) /
+                          oisStats.maxPoolSize
+                        }
+                      />
+                      %
                     </span>
                   }
-                />
-              </StatCard>
-              <Drawer
-                title="OIS Pool Allocation"
-                className={styles.oisDrawer ?? ""}
-                bodyClassName={styles.oisDrawerBody}
-                footerClassName={styles.oisDrawerFooter}
-                footer={
-                  <>
-                    <Button
-                      variant="solid"
-                      size="sm"
-                      href="https://staking.pyth.network"
-                      target="_blank"
-                      beforeIcon={Browsers}
-                    >
-                      Open Staking App
-                    </Button>
-                    <Button
-                      variant="outline"
-                      size="sm"
-                      href="https://docs.pyth.network/home/oracle-integrity-staking"
-                      target="_blank"
-                      beforeIcon={BookOpenText}
-                    >
-                      Documentation
-                    </Button>
-                  </>
-                }
-              >
-                <SemicircleMeter
-                  width={420}
-                  height={420}
-                  value={Number(oisStats.poolUtilization)}
-                  maxValue={oisStats.maxPoolSize}
-                  className={styles.oisMeter ?? ""}
-                  aria-label="OIS Pool Utilization"
+                  corner={<ArrowsOutSimple />}
                 >
-                  <TokenIcon className={styles.oisMeterIcon} />
-                  <div className={styles.oisMeterLabel}>OIS Pool</div>
-                </SemicircleMeter>
-                <StatCard
-                  header="Total Staked"
-                  variant="secondary"
-                  nonInteractive
-                  stat={
-                    <>
-                      <TokenIcon />
-                      <FormattedTokens tokens={oisStats.poolUtilization} />
-                    </>
-                  }
-                />
-                <StatCard
-                  header="Pool Capacity"
-                  variant="secondary"
-                  nonInteractive
-                  stat={
+                  <Meter
+                    value={Number(oisStats.poolUtilization)}
+                    maxValue={oisStats.maxPoolSize}
+                    label="OIS Pool"
+                    startLabel={
+                      <span className={styles.tokens}>
+                        <TokenIcon />
+                        <span>
+                          <FormattedTokens tokens={oisStats.poolUtilization} />
+                        </span>
+                      </span>
+                    }
+                    endLabel={
+                      <span className={styles.tokens}>
+                        <TokenIcon />
+                        <span>
+                          <FormattedTokens
+                            tokens={BigInt(oisStats.maxPoolSize)}
+                          />
+                        </span>
+                      </span>
+                    }
+                  />
+                </StatCard>
+                <Drawer
+                  title="OIS Pool Allocation"
+                  className={styles.oisDrawer ?? ""}
+                  bodyClassName={styles.oisDrawerBody}
+                  footerClassName={styles.oisDrawerFooter}
+                  footer={
                     <>
-                      <TokenIcon />
-                      <FormattedTokens tokens={BigInt(oisStats.maxPoolSize)} />
+                      <Button
+                        variant="solid"
+                        size="sm"
+                        href="https://staking.pyth.network"
+                        target="_blank"
+                        beforeIcon={Browsers}
+                      >
+                        Open Staking App
+                      </Button>
+                      <Button
+                        variant="outline"
+                        size="sm"
+                        href="https://docs.pyth.network/home/oracle-integrity-staking"
+                        target="_blank"
+                        beforeIcon={BookOpenText}
+                      >
+                        Documentation
+                      </Button>
                     </>
                   }
-                />
-                <OisApyHistory apyHistory={oisStats.apyHistory ?? []} />
-                <InfoBox
-                  icon={<ShieldChevron />}
-                  header="Oracle Integrity Staking (OIS)"
                 >
-                  OIS allows anyone to help secure Pyth and protect DeFi.
-                  Through decentralized staking rewards and slashing, OIS
-                  incentivizes Pyth publishers to maintain high-quality data
-                  contributions. PYTH holders can stake to publishers to further
-                  reinforce oracle security. Rewards are programmatically
-                  distributed to high quality publishers and the stakers
-                  supporting them to strengthen oracle integrity.
-                </InfoBox>
-              </Drawer>
-            </DrawerTrigger>
+                  <SemicircleMeter
+                    width={420}
+                    height={420}
+                    value={Number(oisStats.poolUtilization)}
+                    maxValue={oisStats.maxPoolSize}
+                    className={styles.oisMeter ?? ""}
+                    aria-label="OIS Pool Utilization"
+                  >
+                    <TokenIcon className={styles.oisMeterIcon} />
+                    <div className={styles.oisMeterLabel}>OIS Pool</div>
+                  </SemicircleMeter>
+                  <StatCard
+                    header="Total Staked"
+                    variant="secondary"
+                    nonInteractive
+                    stat={
+                      <>
+                        <TokenIcon />
+                        <FormattedTokens tokens={oisStats.poolUtilization} />
+                      </>
+                    }
+                  />
+                  <StatCard
+                    header="Pool Capacity"
+                    variant="secondary"
+                    nonInteractive
+                    stat={
+                      <>
+                        <TokenIcon />
+                        <FormattedTokens
+                          tokens={BigInt(oisStats.maxPoolSize)}
+                        />
+                      </>
+                    }
+                  />
+                  <OisApyHistory apyHistory={oisStats.apyHistory ?? []} />
+                  <InfoBox
+                    icon={<ShieldChevron />}
+                    header="Oracle Integrity Staking (OIS)"
+                  >
+                    OIS allows anyone to help secure Pyth and protect DeFi.
+                    Through decentralized staking rewards and slashing, OIS
+                    incentivizes Pyth publishers to maintain high-quality data
+                    contributions. PYTH holders can stake to publishers to
+                    further reinforce oracle security. Rewards are
+                    programmatically distributed to high quality publishers and
+                    the stakers supporting them to strengthen oracle integrity.
+                  </InfoBox>
+                </Drawer>
+              </DrawerTrigger>
+            )}
           </section>
         </section>
         <TabRoot>
           <Tabs
             label="Price Feed Navigation"
-            prefix={`/publishers/${key}`}
+            prefix={`/publishers/${ClusterToName[parsedCluster]}/${key}`}
             items={[
               { segment: undefined, children: "Performance" },
               {

+ 14 - 7
apps/insights/src/components/Publisher/performance.tsx

@@ -13,7 +13,7 @@ import { getPriceFeeds } from "./get-price-feeds";
 import styles from "./performance.module.scss";
 import { TopFeedsTable } from "./top-feeds-table";
 import { getPublishers } from "../../services/clickhouse";
-import { Cluster } from "../../services/pyth";
+import { ClusterToName, parseCluster } from "../../services/pyth";
 import { Status } from "../../status";
 import {
   ExplainActive,
@@ -31,15 +31,21 @@ const PUBLISHER_SCORE_WIDTH = 24;
 
 type Props = {
   params: Promise<{
+    cluster: string;
     key: string;
   }>;
 };
 
 export const Performance = async ({ params }: Props) => {
-  const { key } = await params;
+  const { key, cluster } = await params;
+  const parsedCluster = parseCluster(cluster);
+
+  if (parsedCluster === undefined) {
+    notFound();
+  }
   const [publishers, priceFeeds] = await Promise.all([
-    getPublishers(),
-    getPriceFeeds(Cluster.Pythnet, key),
+    getPublishers(parsedCluster),
+    getPriceFeeds(parsedCluster, key),
   ]);
   const slicedPublishers = sliceAround(
     publishers,
@@ -114,7 +120,7 @@ export const Performance = async ({ params }: Props) => {
                 ),
                 activeFeeds: (
                   <Link
-                    href={`/publishers/${publisher.key}/price-feeds?status=Active`}
+                    href={`/publishers/${ClusterToName[parsedCluster]}/${publisher.key}/price-feeds?status=Active`}
                     invert
                   >
                     {publisher.activeFeeds}
@@ -122,7 +128,7 @@ export const Performance = async ({ params }: Props) => {
                 ),
                 inactiveFeeds: (
                   <Link
-                    href={`/publishers/${publisher.key}/price-feeds?status=Inactive`}
+                    href={`/publishers/${ClusterToName[parsedCluster]}/${publisher.key}/price-feeds?status=Inactive`}
                     invert
                   >
                     {publisher.inactiveFeeds}
@@ -136,6 +142,7 @@ export const Performance = async ({ params }: Props) => {
                 ),
                 name: (
                   <PublisherTag
+                    cluster={parsedCluster}
                     publisherKey={publisher.key}
                     {...(knownPublisher && {
                       name: knownPublisher.name,
@@ -145,7 +152,7 @@ export const Performance = async ({ params }: Props) => {
                 ),
               },
               ...(publisher.key !== key && {
-                href: `/publishers/${publisher.key}`,
+                href: `/publishers/${ClusterToName[parsedCluster]}/${publisher.key}`,
               }),
             };
           })}

+ 5 - 1
apps/insights/src/components/Publisher/price-feed-drawer-provider.tsx

@@ -12,6 +12,7 @@ import {
   use,
 } from "react";
 
+import type { Cluster } from "../../services/pyth";
 import type { Status } from "../../status";
 import { PriceComponentDrawer } from "../PriceComponentDrawer";
 import { PriceFeedTag } from "../PriceFeedTag";
@@ -25,6 +26,7 @@ type PriceFeedDrawerProviderProps = Omit<
   "value"
 > & {
   publisherKey: string;
+  cluster: Cluster;
   priceFeeds: PriceFeed[];
 };
 
@@ -52,6 +54,7 @@ const PriceFeedDrawerProviderImpl = ({
   publisherKey,
   priceFeeds,
   children,
+  cluster,
 }: PriceFeedDrawerProviderProps) => {
   const logger = useLogger();
   const [selectedSymbol, setSelectedSymbol] = useQueryState(
@@ -91,11 +94,12 @@ const PriceFeedDrawerProviderImpl = ({
           rank={selectedFeed.rank}
           score={selectedFeed.score}
           symbol={selectedFeed.symbol}
+          displaySymbol={selectedFeed.displaySymbol}
           status={selectedFeed.status}
           firstEvaluation={selectedFeed.firstEvaluation ?? new Date()}
-          navigateButtonText="Open Feed"
           navigateHref={feedHref}
           title={<PriceFeedTag symbol={selectedFeed.symbol} />}
+          cluster={cluster}
         />
       )}
     </PriceFeedDrawerContext>

+ 6 - 4
apps/insights/src/components/Publisher/price-feeds-card.tsx

@@ -4,7 +4,7 @@ import { type ComponentProps, useCallback } from "react";
 
 import { useSelectPriceFeed } from "./price-feed-drawer-provider";
 import { usePriceFeeds } from "../../hooks/use-price-feeds";
-import { Cluster, ClusterToName } from "../../services/pyth";
+import type { Cluster } from "../../services/pyth";
 import { PriceComponentsCard } from "../PriceComponentsCard";
 import { PriceFeedTag } from "../PriceFeedTag";
 
@@ -13,6 +13,7 @@ type Props = Omit<
   "onPriceComponentAction" | "priceComponents"
 > & {
   publisherKey: string;
+  cluster: Cluster;
   priceFeeds: (Pick<
     ComponentProps<typeof PriceComponentsCard>["priceComponents"][number],
     "score" | "uptimeScore" | "deviationScore" | "stalledScore" | "status"
@@ -24,6 +25,7 @@ type Props = Omit<
 export const PriceFeedsCard = ({
   priceFeeds,
   publisherKey,
+  cluster,
   ...props
 }: Props) => {
   const feeds = usePriceFeeds();
@@ -39,14 +41,14 @@ export const PriceFeedsCard = ({
         const contextFeed = feeds.get(feed.symbol);
         if (contextFeed) {
           return {
-            id: `${contextFeed.key}-${ClusterToName[Cluster.Pythnet]}`,
-            feedKey: contextFeed.key,
+            id: contextFeed.key[cluster],
+            feedKey: contextFeed.key[cluster],
             symbol: feed.symbol,
             score: feed.score,
             uptimeScore: feed.uptimeScore,
             deviationScore: feed.deviationScore,
             stalledScore: feed.stalledScore,
-            cluster: Cluster.Pythnet,
+            cluster,
             status: feed.status,
             publisherKey,
             name: <PriceFeedTag compact symbol={feed.symbol} />,

+ 12 - 3
apps/insights/src/components/Publisher/price-feeds.tsx

@@ -1,17 +1,25 @@
+import { notFound } from "next/navigation";
+
 import { getPriceFeeds } from "./get-price-feeds";
 import { PriceFeedsCard } from "./price-feeds-card";
-import { Cluster } from "../../services/pyth";
+import { parseCluster } from "../../services/pyth";
 import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
   params: Promise<{
+    cluster: string;
     key: string;
   }>;
 };
 
 export const PriceFeeds = async ({ params }: Props) => {
-  const { key } = await params;
-  const feeds = await getPriceFeeds(Cluster.Pythnet, key);
+  const { key, cluster } = await params;
+  const parsedCluster = parseCluster(cluster);
+
+  if (parsedCluster === undefined) {
+    notFound();
+  }
+  const feeds = await getPriceFeeds(parsedCluster, key);
   const metricsTime = feeds.find((feed) => feed.ranking !== undefined)?.ranking
     ?.time;
 
@@ -22,6 +30,7 @@ export const PriceFeeds = async ({ params }: Props) => {
       metricsTime={metricsTime}
       nameLoadingSkeleton={<PriceFeedTag compact isLoading />}
       publisherKey={key}
+      cluster={parsedCluster}
       priceFeeds={feeds.map(({ ranking, feed, status }) => ({
         symbol: feed.symbol,
         score: ranking?.final_score,

+ 4 - 0
apps/insights/src/components/PublisherTag/index.module.scss

@@ -62,6 +62,10 @@
     }
   }
 
+  .testBadge {
+    margin-left: theme.spacing(4);
+  }
+
   &[data-loading] {
     .icon {
       border-radius: theme.border-radius("full");

+ 16 - 1
apps/insights/src/components/PublisherTag/index.tsx

@@ -1,13 +1,18 @@
 import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
+import { Badge } from "@pythnetwork/component-library/Badge";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import clsx from "clsx";
 import type { ComponentProps, ReactNode } from "react";
 
 import styles from "./index.module.scss";
 import { omitKeys } from "../../omit-keys";
+import { Cluster } from "../../services/pyth";
 import { PublisherKey } from "../PublisherKey";
 
-type Props = ComponentProps<"div"> & { compact?: boolean | undefined } & (
+type Props = ComponentProps<"div"> & {
+  compact?: boolean | undefined;
+  cluster?: Cluster | undefined;
+} & (
     | { isLoading: true }
     | ({
         isLoading?: false;
@@ -37,6 +42,16 @@ export const PublisherTag = ({ className, ...props }: Props) => (
       <div className={styles.icon}>{props.icon ?? <UndisclosedIcon />}</div>
     )}
     <Contents {...props} />
+    {props.cluster === Cluster.PythtestConformance && (
+      <Badge
+        variant="muted"
+        style="filled"
+        size="xs"
+        className={styles.testBadge}
+      >
+        test
+      </Badge>
+    )}
   </div>
 );
 

+ 38 - 24
apps/insights/src/components/Publishers/index.tsx

@@ -9,6 +9,7 @@ import styles from "./index.module.scss";
 import { PublishersCard } from "./publishers-card";
 import { getPublishers } from "../../services/clickhouse";
 import { getPublisherCaps } from "../../services/hermes";
+import { Cluster } from "../../services/pyth";
 import {
   getDelState,
   getClaimableRewards,
@@ -24,13 +25,15 @@ import { TokenIcon } from "../TokenIcon";
 const INITIAL_REWARD_POOL_SIZE = 60_000_000_000_000n;
 
 export const Publishers = async () => {
-  const [publishers, oisStats] = await Promise.all([
-    getPublishers(),
-    getOisStats(),
-  ]);
+  const [pythnetPublishers, pythtestConformancePublishers, oisStats] =
+    await Promise.all([
+      getPublishers(Cluster.Pythnet),
+      getPublishers(Cluster.PythtestConformance),
+      getOisStats(),
+    ]);
 
-  const rankingTime = publishers[0]?.timestamp;
-  const scoreTime = publishers[0]?.scoreTime;
+  const rankingTime = pythnetPublishers[0]?.timestamp;
+  const scoreTime = pythnetPublishers[0]?.scoreTime;
 
   return (
     <div className={styles.publishers}>
@@ -56,16 +59,16 @@ export const Publishers = async () => {
           <StatCard
             variant="primary"
             header="Active Publishers"
-            stat={publishers.length}
+            stat={pythnetPublishers.length}
           />
           <StatCard
             header="Average Feed Score"
             corner={<ExplainAverage scoreTime={scoreTime} />}
             stat={(
-              publishers.reduce(
+              pythnetPublishers.reduce(
                 (sum, publisher) => sum + publisher.averageScore,
                 0,
-              ) / publishers.length
+              ) / pythnetPublishers.length
             ).toFixed(2)}
           />
           <Card
@@ -132,21 +135,11 @@ export const Publishers = async () => {
         <PublishersCard
           className={styles.publishersCard}
           explainAverage={<ExplainAverage scoreTime={scoreTime} />}
-          publishers={publishers.map(
-            ({ key, rank, inactiveFeeds, activeFeeds, averageScore }) => {
-              const knownPublisher = lookupPublisher(key);
-              return {
-                id: key,
-                ranking: rank,
-                activeFeeds: activeFeeds,
-                inactiveFeeds: inactiveFeeds,
-                averageScore,
-                ...(knownPublisher && {
-                  name: knownPublisher.name,
-                  icon: <PublisherIcon knownPublisher={knownPublisher} />,
-                }),
-              };
-            },
+          pythnetPublishers={pythnetPublishers.map((publisher) =>
+            toTableRow(publisher),
+          )}
+          pythtestConformancePublishers={pythtestConformancePublishers.map(
+            (publisher) => toTableRow(publisher),
           )}
         />
       </div>
@@ -154,6 +147,27 @@ export const Publishers = async () => {
   );
 };
 
+const toTableRow = ({
+  key,
+  rank,
+  inactiveFeeds,
+  activeFeeds,
+  averageScore,
+}: Awaited<ReturnType<typeof getPublishers>>[number]) => {
+  const knownPublisher = lookupPublisher(key);
+  return {
+    id: key,
+    ranking: rank,
+    activeFeeds: activeFeeds,
+    inactiveFeeds: inactiveFeeds,
+    averageScore,
+    ...(knownPublisher && {
+      name: knownPublisher.name,
+      icon: <PublisherIcon knownPublisher={knownPublisher} />,
+    }),
+  };
+};
+
 const getOisStats = async () => {
   const [delState, claimableRewards, distributedRewards, publisherCaps] =
     await Promise.all([

+ 79 - 21
apps/insights/src/components/Publishers/publishers-card.tsx

@@ -1,20 +1,25 @@
 "use client";
 
 import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
+import { Database } from "@phosphor-icons/react/dist/ssr/Database";
+import { useLogger } from "@pythnetwork/app-logger";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Card } from "@pythnetwork/component-library/Card";
 import { Link } from "@pythnetwork/component-library/Link";
 import { Paginator } from "@pythnetwork/component-library/Paginator";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
+import { Select } from "@pythnetwork/component-library/Select";
 import {
   type RowConfig,
   type SortDescriptor,
   Table,
 } from "@pythnetwork/component-library/Table";
-import { type ReactNode, Suspense, useMemo } from "react";
+import { useQueryState, parseAsStringEnum } from "nuqs";
+import { type ReactNode, Suspense, useMemo, useCallback } from "react";
 import { useFilter, useCollator } from "react-aria";
 
 import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
+import { CLUSTER_NAMES } from "../../services/pyth";
 import { ExplainActive, ExplainInactive } from "../Explanations";
 import { NoResults } from "../NoResults";
 import { PublisherTag } from "../PublisherTag";
@@ -26,7 +31,8 @@ const PUBLISHER_SCORE_WIDTH = 38;
 
 type Props = {
   className?: string | undefined;
-  publishers: Publisher[];
+  pythnetPublishers: Publisher[];
+  pythtestConformancePublishers: Publisher[];
   explainAverage: ReactNode;
 };
 
@@ -41,15 +47,33 @@ type Publisher = {
   | { name?: undefined; icon?: undefined }
 );
 
-export const PublishersCard = ({ publishers, ...props }: Props) => (
+export const PublishersCard = ({
+  pythnetPublishers,
+  pythtestConformancePublishers,
+  ...props
+}: Props) => (
   <Suspense fallback={<PublishersCardContents isLoading {...props} />}>
-    <ResolvedPublishersCard publishers={publishers} {...props} />
+    <ResolvedPublishersCard
+      pythnetPublishers={pythnetPublishers}
+      pythtestConformancePublishers={pythtestConformancePublishers}
+      {...props}
+    />
   </Suspense>
 );
 
-const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
+const ResolvedPublishersCard = ({
+  pythnetPublishers,
+  pythtestConformancePublishers,
+  ...props
+}: Props) => {
+  const logger = useLogger();
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const [cluster, setCluster] = useQueryState(
+    "cluster",
+    parseAsStringEnum([...CLUSTER_NAMES]).withDefault("pythnet"),
+  );
+
   const {
     search,
     sortDescriptor,
@@ -64,7 +88,7 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
     numPages,
     mkPageLink,
   } = useQueryParamFilterPagination(
-    publishers,
+    cluster === "pythnet" ? pythnetPublishers : pythtestConformancePublishers,
     (publisher, search) =>
       filter.contains(publisher.id, search) ||
       (publisher.name !== undefined && filter.contains(publisher.name, search)),
@@ -108,7 +132,7 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
           ...publisher
         }) => ({
           id,
-          href: `/publishers/${id}`,
+          href: `/publishers/${cluster}/${id}`,
           data: {
             ranking: <Ranking>{ranking}</Ranking>,
             name: (
@@ -121,13 +145,16 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
               />
             ),
             activeFeeds: (
-              <Link href={`/publishers/${id}/price-feeds?status=Active`} invert>
+              <Link
+                href={`/publishers/${cluster}/${id}/price-feeds?status=Active`}
+                invert
+              >
                 {activeFeeds}
               </Link>
             ),
             inactiveFeeds: (
               <Link
-                href={`/publishers/${id}/price-feeds?status=Inactive`}
+                href={`/publishers/${cluster}/${id}/price-feeds?status=Inactive`}
                 invert
               >
                 {inactiveFeeds}
@@ -139,7 +166,17 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
           },
         }),
       ),
-    [paginatedItems],
+    [paginatedItems, cluster],
+  );
+
+  const updateCluster = useCallback(
+    (newCluster: (typeof CLUSTER_NAMES)[number]) => {
+      updatePage(1);
+      setCluster(newCluster).catch((error: unknown) => {
+        logger.error("Failed to update asset class", error);
+      });
+    },
+    [updatePage, setCluster, logger],
   );
 
   return (
@@ -155,6 +192,8 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
       onPageSizeChange={updatePageSize}
       onPageChange={updatePage}
       mkPageLink={mkPageLink}
+      cluster={cluster}
+      onChangeCluster={updateCluster}
       rows={rows}
       {...props}
     />
@@ -177,6 +216,8 @@ type PublishersCardContentsProps = Pick<Props, "className" | "explainAverage"> &
         onPageSizeChange: (newPageSize: number) => void;
         onPageChange: (newPage: number) => void;
         mkPageLink: (page: number) => string;
+        cluster: (typeof CLUSTER_NAMES)[number];
+        onChangeCluster: (value: (typeof CLUSTER_NAMES)[number]) => void;
         rows: RowConfig<
           "ranking" | "name" | "activeFeeds" | "inactiveFeeds" | "averageScore"
         >[];
@@ -202,17 +243,34 @@ const PublishersCardContents = ({
       </>
     }
     toolbar={
-      <SearchInput
-        size="sm"
-        width={60}
-        placeholder="Publisher key or name"
-        {...(props.isLoading
-          ? { isPending: true, isDisabled: true }
-          : {
-              value: props.search,
-              onChange: props.onSearchChange,
-            })}
-      />
+      <>
+        <Select
+          label="Cluster"
+          size="sm"
+          variant="outline"
+          hideLabel
+          options={CLUSTER_NAMES}
+          icon={Database}
+          {...(props.isLoading
+            ? { isPending: true, buttonLabel: "Cluster" }
+            : {
+                placement: "bottom end",
+                selectedKey: props.cluster,
+                onSelectionChange: props.onChangeCluster,
+              })}
+        />
+        <SearchInput
+          size="sm"
+          width={60}
+          placeholder="Publisher key or name"
+          {...(props.isLoading
+            ? { isPending: true, isDisabled: true }
+            : {
+                value: props.search,
+                onChange: props.onSearchChange,
+              })}
+        />
+      </>
     }
     {...(!props.isLoading && {
       footer: (

+ 34 - 17
apps/insights/src/components/Root/index.tsx

@@ -27,7 +27,10 @@ type Props = {
 };
 
 export const Root = async ({ children }: Props) => {
-  const publishers = await getPublishers();
+  const publishers = await Promise.all([
+    getPublishersForSearchDialog(Cluster.Pythnet),
+    getPublishersForSearchDialog(Cluster.PythtestConformance),
+  ]);
 
   return (
     <BaseRoot
@@ -37,19 +40,7 @@ export const Root = async ({ children }: Props) => {
       providers={[NuqsAdapter, LivePriceDataProvider, PriceFeedsProvider]}
       className={styles.root}
     >
-      <SearchDialogProvider
-        publishers={publishers.map((publisher) => {
-          const knownPublisher = lookupPublisher(publisher.key);
-          return {
-            id: publisher.key,
-            averageScore: publisher.averageScore,
-            ...(knownPublisher && {
-              name: knownPublisher.name,
-              icon: <PublisherIcon knownPublisher={knownPublisher} />,
-            }),
-          };
-        })}
-      >
+      <SearchDialogProvider publishers={publishers.flat()}>
         <TabRoot className={styles.tabRoot ?? ""}>
           <Header className={styles.header} />
           <main className={styles.main}>
@@ -62,17 +53,43 @@ export const Root = async ({ children }: Props) => {
   );
 };
 
+const getPublishersForSearchDialog = async (cluster: Cluster) => {
+  const publishers = await getPublishers(cluster);
+  return publishers.map((publisher) => {
+    const knownPublisher = lookupPublisher(publisher.key);
+
+    return {
+      id: publisher.key,
+      averageScore: publisher.averageScore,
+      cluster,
+      ...(knownPublisher && {
+        name: knownPublisher.name,
+        icon: <PublisherIcon knownPublisher={knownPublisher} />,
+      }),
+    };
+  });
+};
+
 const PriceFeedsProvider = async ({ children }: { children: ReactNode }) => {
-  const feeds = await getFeeds(Cluster.Pythnet);
+  const [pythnetFeeds, pythtestConformanceFeeds] = await Promise.all([
+    getFeeds(Cluster.Pythnet),
+    getFeeds(Cluster.PythtestConformance),
+  ]);
 
   const feedMap = new Map(
-    feeds.map((feed) => [
+    pythnetFeeds.map((feed) => [
       feed.symbol,
       {
         displaySymbol: feed.product.display_symbol,
         icon: <PriceFeedIcon symbol={feed.product.display_symbol} />,
         description: feed.product.description,
-        key: feed.product.price_account,
+        key: {
+          [Cluster.Pythnet]: feed.product.price_account,
+          [Cluster.PythtestConformance]:
+            pythtestConformanceFeeds.find(
+              (conformanceFeed) => conformanceFeed.symbol === feed.symbol,
+            )?.product.price_account ?? "",
+        },
         assetClass: feed.product.asset_type,
       },
     ]),

+ 4 - 1
apps/insights/src/components/Root/search-dialog.tsx

@@ -27,6 +27,7 @@ import { useCollator, useFilter } from "react-aria";
 
 import styles from "./search-dialog.module.scss";
 import { usePriceFeeds } from "../../hooks/use-price-feeds";
+import { Cluster, ClusterToName } from "../../services/pyth";
 import { AssetClassTag } from "../AssetClassTag";
 import { NoResults } from "../NoResults";
 import { PriceFeedTag } from "../PriceFeedTag";
@@ -47,6 +48,7 @@ type Props = {
   publishers: ({
     id: string;
     averageScore: number;
+    cluster: Cluster;
   } & (
     | { name: string; icon: ReactNode }
     | { name?: undefined; icon?: undefined }
@@ -208,7 +210,7 @@ export const SearchDialogProvider = ({ children, publishers }: Props) => {
                       : (result.name ?? result.id)
                   }
                   className={styles.item ?? ""}
-                  href={`${result.type === ResultType.PriceFeed ? "/price-feeds" : "/publishers"}/${encodeURIComponent(result.id)}`}
+                  href={`${result.type === ResultType.PriceFeed ? "/price-feeds" : `/publishers/${ClusterToName[result.cluster]}`}/${encodeURIComponent(result.id)}`}
                   data-is-first={result.id === results[0]?.id ? "" : undefined}
                 >
                   <div className={styles.itemType}>
@@ -240,6 +242,7 @@ export const SearchDialogProvider = ({ children, publishers }: Props) => {
                       <PublisherTag
                         className={styles.itemTag}
                         compact
+                        cluster={result.cluster}
                         publisherKey={result.id}
                         {...(result.name && {
                           name: result.name,

+ 17 - 6
apps/insights/src/hooks/use-live-price-data.tsx

@@ -37,9 +37,9 @@ export const LivePriceDataProvider = (props: LivePriceDataProviderProps) => {
   return <LivePriceDataContext value={priceData} {...props} />;
 };
 
-export const useLivePriceData = (feedKey: string) => {
+export const useLivePriceData = (cluster: Cluster, feedKey: string) => {
   const { priceData, prevPriceData, addSubscription, removeSubscription } =
-    useLivePriceDataContext();
+    useLivePriceDataContext()[cluster];
 
   useEffect(() => {
     addSubscription(feedKey);
@@ -55,10 +55,11 @@ export const useLivePriceData = (feedKey: string) => {
 };
 
 export const useLivePriceComponent = (
+  cluster: Cluster,
   feedKey: string,
   publisherKeyAsBase58: string,
 ) => {
-  const { current, prev } = useLivePriceData(feedKey);
+  const { current, prev } = useLivePriceData(cluster, feedKey);
   const publisherKey = useMemo(
     () => new PublicKey(publisherKeyAsBase58),
     [publisherKeyAsBase58],
@@ -75,6 +76,16 @@ export const useLivePriceComponent = (
 };
 
 const usePriceData = () => {
+  const pythnetPriceData = usePriceDataForCluster(Cluster.Pythnet);
+  const pythtestPriceData = usePriceDataForCluster(Cluster.PythtestConformance);
+
+  return {
+    [Cluster.Pythnet]: pythnetPriceData,
+    [Cluster.PythtestConformance]: pythtestPriceData,
+  };
+};
+
+const usePriceDataForCluster = (cluster: Cluster) => {
   const feedSubscriptions = useMap<string, number>([]);
   const [feedKeys, setFeedKeys] = useState<string[]>([]);
   const prevPriceData = useMap<string, PriceData>([]);
@@ -89,7 +100,7 @@ const usePriceData = () => {
     const uninitializedFeedKeys = feedKeys.filter((key) => !priceData.has(key));
     if (uninitializedFeedKeys.length > 0) {
       getAssetPricesFromAccounts(
-        Cluster.Pythnet,
+        cluster,
         uninitializedFeedKeys.map((key) => new PublicKey(key)),
       )
         .then((initialPrices) => {
@@ -107,7 +118,7 @@ const usePriceData = () => {
 
     // Then, we create a subscription to update prices live.
     const connection = subscribe(
-      Cluster.Pythnet,
+      cluster,
       feedKeys.map((key) => new PublicKey(key)),
       ({ price_account }, data) => {
         if (price_account) {
@@ -128,7 +139,7 @@ const usePriceData = () => {
         logger.error("Failed to unsubscribe from price updates", error);
       });
     };
-  }, [feedKeys, logger, priceData, prevPriceData]);
+  }, [feedKeys, logger, priceData, prevPriceData, cluster]);
 
   const addSubscription = useCallback(
     (key: string) => {

+ 3 - 1
apps/insights/src/hooks/use-price-feeds.tsx

@@ -2,6 +2,8 @@
 
 import { type ReactNode, type ComponentProps, createContext, use } from "react";
 
+import type { Cluster } from "../services/pyth";
+
 const PriceFeedsContext = createContext<undefined | PriceFeeds>(undefined);
 
 export const PriceFeedsProvider = (
@@ -23,7 +25,7 @@ export type PriceFeed = {
   displaySymbol: string;
   icon: ReactNode;
   description: string;
-  key: string;
+  key: Record<Cluster, string>;
   assetClass: string;
 };
 

+ 15 - 9
apps/insights/src/services/clickhouse.ts

@@ -8,7 +8,7 @@ import { CLICKHOUSE } from "../config/server";
 
 const client = createClient(CLICKHOUSE);
 
-export const getPublishers = async () =>
+export const getPublishers = async (cluster: Cluster) =>
   safeQuery(
     z.array(
       z.strictObject({
@@ -63,7 +63,7 @@ export const getPublishers = async () =>
           )
           ORDER BY rank ASC, timestamp
         `,
-      query_params: { cluster: "pythnet" },
+      query_params: { cluster: ClusterToName[cluster] },
     },
   );
 
@@ -118,7 +118,7 @@ export const getRankingsBySymbol = async (symbol: string) =>
         symbol,
         cluster,
         publisher,
-      first_ranking_time,
+        first_ranking_time,
         uptime_score,
         deviation_score,
         stalled_score,
@@ -177,7 +177,10 @@ export const getYesterdaysPrices = async (symbols: string[]) =>
     },
   );
 
-export const getPublisherRankingHistory = async (key: string) =>
+export const getPublisherRankingHistory = async (
+  cluster: Cluster,
+  key: string,
+) =>
   safeQuery(
     z.array(
       z.strictObject({
@@ -191,13 +194,13 @@ export const getPublisherRankingHistory = async (key: string) =>
             SELECT timestamp, rank
             FROM publishers_ranking
             WHERE publisher = {key: String}
-            AND cluster = 'pythnet'
+            AND cluster = {cluster: String}
             ORDER BY timestamp DESC
             LIMIT 30
           )
           ORDER BY timestamp ASC
         `,
-      query_params: { key },
+      query_params: { key, cluster: ClusterToName[cluster] },
     },
   );
 
@@ -279,7 +282,10 @@ export const getFeedPriceHistory = async (
     },
   );
 
-export const getPublisherAverageScoreHistory = async (key: string) =>
+export const getPublisherAverageScoreHistory = async (
+  cluster: Cluster,
+  key: string,
+) =>
   safeQuery(
     z.array(
       z.strictObject({
@@ -295,14 +301,14 @@ export const getPublisherAverageScoreHistory = async (key: string) =>
               avg(final_score) AS averageScore
             FROM publisher_quality_ranking
             WHERE publisher = {key: String}
-            AND cluster = 'pythnet'
+            AND cluster = {cluster: String}
             GROUP BY time
             ORDER BY time DESC
             LIMIT 30
           )
           ORDER BY time ASC
         `,
-      query_params: { key },
+      query_params: { key, cluster: ClusterToName[cluster] },
     },
   );
 

+ 2 - 0
apps/insights/src/services/hermes.ts

@@ -6,3 +6,5 @@ const client = new HermesClient("https://hermes.pyth.network");
 
 export const getPublisherCaps = async () =>
   client.getLatestPublisherCaps({ parsed: true });
+
+export const foo = async () => client.getPriceFeeds({});

+ 5 - 0
apps/insights/src/services/pyth.ts

@@ -31,6 +31,11 @@ export const toCluster = (name: (typeof CLUSTER_NAMES)[number]): Cluster => {
   }
 };
 
+export const parseCluster = (name: string): Cluster | undefined =>
+  (CLUSTER_NAMES as readonly string[]).includes(name)
+    ? toCluster(name as (typeof CLUSTER_NAMES)[number])
+    : undefined;
+
 const mkConnection = (cluster: Cluster) =>
   new Connection(getPythClusterApiUrl(ClusterToName[cluster]));
 

+ 11 - 5
packages/component-library/src/InfoBox/index.tsx

@@ -1,15 +1,21 @@
-import type { ReactNode } from "react";
+import clsx from "clsx";
+import type { ComponentProps, ReactNode } from "react";
 
 import styles from "./index.module.scss";
 
-type Props = {
+type Props = ComponentProps<"div"> & {
   icon: ReactNode;
   header: ReactNode;
-  children: ReactNode;
 };
 
-export const InfoBox = ({ icon, header, children }: Props) => (
-  <div className={styles.infoBox}>
+export const InfoBox = ({
+  icon,
+  header,
+  children,
+  className,
+  ...props
+}: Props) => (
+  <div className={clsx(className, styles.infoBox)} {...props}>
     <div className={styles.icon}>{icon}</div>
     <div className={styles.body}>
       <h3 className={styles.header}>{header}</h3>