Explorar el Código

Merge pull request #2223 from pyth-network/cprussin/add-price-feed-component-drawer

feat(insights): add price component drawer
Connor Prussin hace 10 meses
padre
commit
b0a75f6842
Se han modificado 59 ficheros con 2365 adiciones y 996 borrados
  1. 2 1
      apps/insights/package.json
  2. 32 0
      apps/insights/src/app/component-score-history/route.ts
  3. 2 2
      apps/insights/src/app/price-feeds/[slug]/layout.ts
  4. 0 0
      apps/insights/src/app/publishers/[key]/price-feeds/page.ts
  5. 3 3
      apps/insights/src/components/ChangePercent/index.tsx
  6. 151 39
      apps/insights/src/components/LivePrices/index.tsx
  7. 10 3
      apps/insights/src/components/NoResults/index.module.scss
  8. 27 10
      apps/insights/src/components/NoResults/index.tsx
  9. 20 0
      apps/insights/src/components/PriceComponentDrawer/index.module.scss
  10. 189 0
      apps/insights/src/components/PriceComponentDrawer/index.tsx
  11. 5 2
      apps/insights/src/components/PriceFeed/layout.tsx
  12. 165 85
      apps/insights/src/components/PriceFeed/publishers-card.tsx
  13. 59 22
      apps/insights/src/components/PriceFeed/publishers.tsx
  14. 8 6
      apps/insights/src/components/PriceFeedTag/index.module.scss
  15. 2 2
      apps/insights/src/components/PriceFeeds/coming-soon-list.tsx
  16. 2 2
      apps/insights/src/components/PriceFeeds/index.tsx
  17. 1 1
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  18. 31 0
      apps/insights/src/components/Publisher/get-price-feeds.tsx
  19. 0 24
      apps/insights/src/components/Publisher/get-rankings-with-data.ts
  20. 293 276
      apps/insights/src/components/Publisher/layout.tsx
  21. 69 57
      apps/insights/src/components/Publisher/performance.tsx
  22. 109 0
      apps/insights/src/components/Publisher/price-feed-drawer-provider.tsx
  23. 8 0
      apps/insights/src/components/Publisher/price-feeds-card.module.scss
  24. 83 48
      apps/insights/src/components/Publisher/price-feeds-card.tsx
  25. 15 11
      apps/insights/src/components/Publisher/price-feeds.tsx
  26. 64 0
      apps/insights/src/components/Publisher/top-feeds-table.tsx
  27. 2 2
      apps/insights/src/components/PublisherTag/index.module.scss
  28. 2 2
      apps/insights/src/components/Publishers/index.tsx
  29. 1 1
      apps/insights/src/components/Publishers/publishers-card.tsx
  30. 7 4
      apps/insights/src/components/Root/index.tsx
  31. 10 8
      apps/insights/src/components/Root/search-dialog.module.scss
  32. 23 21
      apps/insights/src/components/Root/search-dialog.tsx
  33. 53 44
      apps/insights/src/components/Score/index.module.scss
  34. 11 3
      apps/insights/src/components/Score/index.tsx
  35. 18 18
      apps/insights/src/components/ScoreHistory/index.module.scss
  36. 67 59
      apps/insights/src/components/ScoreHistory/index.tsx
  37. 37 0
      apps/insights/src/components/Status/index.tsx
  38. 3 0
      apps/insights/src/config/server.ts
  39. 156 101
      apps/insights/src/services/clickhouse.ts
  40. 72 16
      apps/insights/src/services/pyth.ts
  41. 13 0
      apps/insights/src/status.ts
  42. 11 2
      apps/insights/src/use-data.ts
  43. 2 1
      apps/insights/turbo.json
  44. 20 8
      packages/component-library/src/Badge/index.module.scss
  45. 7 0
      packages/component-library/src/Drawer/index.module.scss
  46. 17 12
      packages/component-library/src/Drawer/index.tsx
  47. 9 0
      packages/component-library/src/ModalDialog/index.tsx
  48. 31 0
      packages/component-library/src/Spinner/index.module.scss
  49. 23 0
      packages/component-library/src/Spinner/index.stories.tsx
  50. 45 0
      packages/component-library/src/Spinner/index.tsx
  51. 20 2
      packages/component-library/src/StatCard/index.module.scss
  52. 6 1
      packages/component-library/src/StatCard/index.tsx
  53. 91 0
      packages/component-library/src/Status/index.module.scss
  54. 46 0
      packages/component-library/src/Status/index.stories.tsx
  55. 42 0
      packages/component-library/src/Status/index.tsx
  56. 121 94
      packages/component-library/src/Table/index.tsx
  57. 24 3
      packages/component-library/src/theme.scss
  58. 24 0
      pnpm-lock.yaml
  59. 1 0
      pnpm-workspace.yaml

+ 2 - 1
apps/insights/package.json

@@ -45,7 +45,8 @@
     "recharts": "catalog:",
     "superjson": "catalog:",
     "swr": "catalog:",
-    "zod": "catalog:"
+    "zod": "catalog:",
+    "zod-validation-error": "catalog:"
   },
   "devDependencies": {
     "@cprussin/eslint-config": "catalog:",

+ 32 - 0
apps/insights/src/app/component-score-history/route.ts

@@ -0,0 +1,32 @@
+import type { NextRequest } from "next/server";
+import { z } from "zod";
+import { fromError } from "zod-validation-error";
+
+import { getFeedScoreHistory } from "../../services/clickhouse";
+import { CLUSTER_NAMES, toCluster } from "../../services/pyth";
+
+export const GET = async (req: NextRequest) => {
+  const parsed = queryParamsSchema.safeParse(
+    Object.fromEntries(
+      Object.keys(queryParamsSchema.shape).map((key) => [
+        key,
+        req.nextUrl.searchParams.get(key),
+      ]),
+    ),
+  );
+  if (parsed.success) {
+    const { cluster, publisherKey, symbol } = parsed.data;
+    const data = await getFeedScoreHistory(cluster, publisherKey, symbol);
+    return Response.json(data);
+  } else {
+    return new Response(fromError(parsed.error).toString(), {
+      status: 400,
+    });
+  }
+};
+
+const queryParamsSchema = z.object({
+  cluster: z.enum(CLUSTER_NAMES).transform((value) => toCluster(value)),
+  publisherKey: z.string(),
+  symbol: z.string().transform((value) => decodeURIComponent(value)),
+});

+ 2 - 2
apps/insights/src/app/price-feeds/[slug]/layout.ts

@@ -1,6 +1,6 @@
 import type { Metadata } from "next";
 
-import { getData } from "../../../services/pyth";
+import { Cluster, getData } from "../../../services/pyth";
 export { PriceFeedLayout as default } from "../../../components/PriceFeed/layout";
 
 export const metadata: Metadata = {
@@ -8,6 +8,6 @@ export const metadata: Metadata = {
 };
 
 export const generateStaticParams = async () => {
-  const data = await getData();
+  const data = await getData(Cluster.Pythnet);
   return data.map(({ symbol }) => ({ slug: encodeURIComponent(symbol) }));
 };

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


+ 3 - 3
apps/insights/src/components/ChangePercent/index.tsx

@@ -100,14 +100,14 @@ const ChangePercentLoaded = ({
   priorPrice,
   feedKey,
 }: ChangePercentLoadedProps) => {
-  const currentPrice = useLivePrice(feedKey);
+  const { current } = useLivePrice(feedKey);
 
-  return currentPrice === undefined ? (
+  return current === undefined ? (
     <ChangeValue className={className} isLoading />
   ) : (
     <PriceDifference
       className={className}
-      currentPrice={currentPrice.aggregate.price}
+      currentPrice={current.aggregate.price}
       priorPrice={priorPrice}
     />
   );

+ 151 - 39
apps/insights/src/components/LivePrices/index.tsx

@@ -2,7 +2,7 @@
 
 import { PlusMinus } from "@phosphor-icons/react/dist/ssr/PlusMinus";
 import { useLogger } from "@pythnetwork/app-logger";
-import type { PriceData } from "@pythnetwork/client";
+import type { PriceData, PriceComponent } from "@pythnetwork/client";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { useMap } from "@react-hookz/web";
 import { PublicKey } from "@solana/web3.js";
@@ -19,7 +19,11 @@ import {
 import { useNumberFormatter, useDateFormatter } from "react-aria";
 
 import styles from "./index.module.scss";
-import { client, subscribe } from "../../services/pyth";
+import {
+  Cluster,
+  subscribe,
+  getAssetPricesFromAccounts,
+} from "../../services/pyth";
 
 export const SKELETON_WIDTH = 20;
 
@@ -27,12 +31,6 @@ const LivePricesContext = createContext<
   ReturnType<typeof usePriceData> | undefined
 >(undefined);
 
-type Price = PriceData & {
-  direction: ChangeDirection;
-};
-
-type ChangeDirection = "up" | "down" | "flat";
-
 type LivePricesProviderProps = Omit<
   ComponentProps<typeof LivePricesContext>,
   "value"
@@ -45,7 +43,8 @@ export const LivePricesProvider = (props: LivePricesProviderProps) => {
 };
 
 export const useLivePrice = (feedKey: string) => {
-  const { priceData, addSubscription, removeSubscription } = useLivePrices();
+  const { priceData, prevPriceData, addSubscription, removeSubscription } =
+    useLivePrices();
 
   useEffect(() => {
     addSubscription(feedKey);
@@ -54,40 +53,130 @@ export const useLivePrice = (feedKey: string) => {
     };
   }, [addSubscription, removeSubscription, feedKey]);
 
-  return priceData.get(feedKey);
+  const current = priceData.get(feedKey);
+  const prev = prevPriceData.get(feedKey);
+
+  return { current, prev };
+};
+
+export const useLivePriceComponent = (
+  feedKey: string,
+  publisherKeyAsBase58: string,
+) => {
+  const { current, prev } = useLivePrice(feedKey);
+  const publisherKey = useMemo(
+    () => new PublicKey(publisherKeyAsBase58),
+    [publisherKeyAsBase58],
+  );
+
+  return {
+    current: current?.priceComponents.find((component) =>
+      component.publisher.equals(publisherKey),
+    ),
+    prev: prev?.priceComponents.find((component) =>
+      component.publisher.equals(publisherKey),
+    ),
+  };
+};
+
+export const LivePrice = ({
+  feedKey,
+  publisherKey,
+}: {
+  feedKey: string;
+  publisherKey?: string | undefined;
+}) =>
+  publisherKey ? (
+    <LiveComponentPrice feedKey={feedKey} publisherKey={publisherKey} />
+  ) : (
+    <LiveAggregatePrice feedKey={feedKey} />
+  );
+
+const LiveAggregatePrice = ({ feedKey }: { feedKey: string }) => {
+  const { prev, current } = useLivePrice(feedKey);
+  return (
+    <Price current={current?.aggregate.price} prev={prev?.aggregate.price} />
+  );
+};
+
+const LiveComponentPrice = ({
+  feedKey,
+  publisherKey,
+}: {
+  feedKey: string;
+  publisherKey: string;
+}) => {
+  const { prev, current } = useLivePriceComponent(feedKey, publisherKey);
+  return <Price current={current?.latest.price} prev={prev?.latest.price} />;
 };
 
-export const LivePrice = ({ feedKey }: { feedKey: string }) => {
+const Price = ({
+  prev,
+  current,
+}: {
+  prev?: number | undefined;
+  current?: number | undefined;
+}) => {
   const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
-  const price = useLivePrice(feedKey);
 
-  return price === undefined ? (
+  return current === undefined ? (
     <Skeleton width={SKELETON_WIDTH} />
   ) : (
-    <span className={styles.price} data-direction={price.direction}>
-      {numberFormatter.format(price.aggregate.price)}
+    <span
+      className={styles.price}
+      data-direction={prev ? getChangeDirection(prev, current) : "flat"}
+    >
+      {numberFormatter.format(current)}
     </span>
   );
 };
 
-export const LiveConfidence = ({ feedKey }: { feedKey: string }) => {
+export const LiveConfidence = ({
+  feedKey,
+  publisherKey,
+}: {
+  feedKey: string;
+  publisherKey?: string | undefined;
+}) =>
+  publisherKey === undefined ? (
+    <LiveAggregateConfidence feedKey={feedKey} />
+  ) : (
+    <LiveComponentConfidence feedKey={feedKey} publisherKey={publisherKey} />
+  );
+
+const LiveAggregateConfidence = ({ feedKey }: { feedKey: string }) => {
+  const { current } = useLivePrice(feedKey);
+  return <Confidence confidence={current?.aggregate.confidence} />;
+};
+
+const LiveComponentConfidence = ({
+  feedKey,
+  publisherKey,
+}: {
+  feedKey: string;
+  publisherKey: string;
+}) => {
+  const { current } = useLivePriceComponent(feedKey, publisherKey);
+  return <Confidence confidence={current?.latest.confidence} />;
+};
+
+const Confidence = ({ confidence }: { confidence?: number | undefined }) => {
   const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
-  const price = useLivePrice(feedKey);
 
   return (
     <span className={styles.confidence}>
       <PlusMinus className={styles.plusMinus} />
-      {price === undefined ? (
+      {confidence === undefined ? (
         <Skeleton width={SKELETON_WIDTH} />
       ) : (
-        <span>{numberFormatter.format(price.aggregate.confidence)}</span>
+        <span>{numberFormatter.format(confidence)}</span>
       )}
     </span>
   );
 };
 
 export const LiveLastUpdated = ({ feedKey }: { feedKey: string }) => {
-  const price = useLivePrice(feedKey);
+  const { current } = useLivePrice(feedKey);
   const formatterWithDate = useDateFormatter({
     dateStyle: "short",
     timeStyle: "medium",
@@ -96,15 +185,15 @@ export const LiveLastUpdated = ({ feedKey }: { feedKey: string }) => {
     timeStyle: "medium",
   });
   const formattedTimestamp = useMemo(() => {
-    if (price) {
-      const timestamp = new Date(Number(price.timestamp * 1000n));
+    if (current) {
+      const timestamp = new Date(Number(current.timestamp * 1000n));
       return isToday(timestamp)
         ? formatterWithoutDate.format(timestamp)
         : formatterWithDate.format(timestamp);
     } else {
       return;
     }
-  }, [price, formatterWithDate, formatterWithoutDate]);
+  }, [current, formatterWithDate, formatterWithoutDate]);
 
   return formattedTimestamp ?? <Skeleton width={SKELETON_WIDTH} />;
 };
@@ -120,9 +209,27 @@ export const LiveValue = <T extends keyof PriceData>({
   field,
   defaultValue,
 }: LiveValueProps<T>) => {
-  const price = useLivePrice(feedKey);
+  const { current } = useLivePrice(feedKey);
 
-  return price?.[field]?.toString() ?? defaultValue;
+  return current?.[field]?.toString() ?? defaultValue;
+};
+
+type LiveComponentValueProps<T extends keyof PriceComponent["latest"]> = {
+  field: T;
+  feedKey: string;
+  publisherKey: string;
+  defaultValue?: ReactNode | undefined;
+};
+
+export const LiveComponentValue = <T extends keyof PriceComponent["latest"]>({
+  feedKey,
+  field,
+  publisherKey,
+  defaultValue,
+}: LiveComponentValueProps<T>) => {
+  const { current } = useLivePriceComponent(feedKey, publisherKey);
+
+  return current?.latest[field].toString() ?? defaultValue;
 };
 
 const isToday = (date: Date) => {
@@ -137,7 +244,8 @@ const isToday = (date: Date) => {
 const usePriceData = () => {
   const feedSubscriptions = useMap<string, number>([]);
   const [feedKeys, setFeedKeys] = useState<string[]>([]);
-  const priceData = useMap<string, Price>([]);
+  const prevPriceData = useMap<string, PriceData>([]);
+  const priceData = useMap<string, PriceData>([]);
   const logger = useLogger();
 
   useEffect(() => {
@@ -147,15 +255,15 @@ const usePriceData = () => {
     // that symbol.
     const uninitializedFeedKeys = feedKeys.filter((key) => !priceData.has(key));
     if (uninitializedFeedKeys.length > 0) {
-      client
-        .getAssetPricesFromAccounts(
-          uninitializedFeedKeys.map((key) => new PublicKey(key)),
-        )
+      getAssetPricesFromAccounts(
+        Cluster.Pythnet,
+        uninitializedFeedKeys.map((key) => new PublicKey(key)),
+      )
         .then((initialPrices) => {
           for (const [i, price] of initialPrices.entries()) {
             const key = uninitializedFeedKeys[i];
-            if (key) {
-              priceData.set(key, { ...price, direction: "flat" });
+            if (key && !priceData.has(key)) {
+              priceData.set(key, price);
             }
           }
         })
@@ -166,14 +274,15 @@ const usePriceData = () => {
 
     // Then, we create a subscription to update prices live.
     const connection = subscribe(
+      Cluster.Pythnet,
       feedKeys.map((key) => new PublicKey(key)),
-      ({ price_account }, price) => {
+      ({ price_account }, data) => {
         if (price_account) {
-          const prevPrice = priceData.get(price_account)?.price;
-          priceData.set(price_account, {
-            ...price,
-            direction: getChangeDirection(prevPrice, price.aggregate.price),
-          });
+          const prevData = priceData.get(price_account);
+          if (prevData) {
+            prevPriceData.set(price_account, prevData);
+          }
+          priceData.set(price_account, data);
         }
       },
     );
@@ -186,7 +295,7 @@ const usePriceData = () => {
         logger.error("Failed to unsubscribe from price updates", error);
       });
     };
-  }, [feedKeys, logger, priceData]);
+  }, [feedKeys, logger, priceData, prevPriceData]);
 
   const addSubscription = useCallback(
     (key: string) => {
@@ -214,6 +323,7 @@ const usePriceData = () => {
 
   return {
     priceData: new Map(priceData),
+    prevPriceData: new Map(prevPriceData),
     addSubscription,
     removeSubscription,
   };
@@ -246,3 +356,5 @@ const getChangeDirection = (
     return "down";
   }
 };
+
+type ChangeDirection = "up" | "down" | "flat";

+ 10 - 3
apps/insights/src/components/NoResults/index.module.scss

@@ -8,13 +8,11 @@
   text-align: center;
   padding: theme.spacing(24) 0;
 
-  .searchIcon {
+  .icon {
     display: grid;
     place-content: center;
     padding: theme.spacing(4);
-    background: theme.color("background", "card-highlight");
     font-size: theme.spacing(6);
-    color: theme.color("highlight");
     border-radius: theme.border-radius("full");
   }
 
@@ -35,4 +33,13 @@
       color: theme.color("paragraph");
     }
   }
+
+  @each $variant in ("success", "error", "warning", "info", "data") {
+    &[data-variant="#{$variant}"] {
+      .icon {
+        background: theme.color("states", $variant, "background");
+        color: theme.color("states", $variant, "normal");
+      }
+    }
+  }
 }

+ 27 - 10
apps/insights/src/components/NoResults/index.tsx

@@ -1,23 +1,40 @@
 import { MagnifyingGlass } from "@phosphor-icons/react/dist/ssr/MagnifyingGlass";
 import { Button } from "@pythnetwork/component-library/Button";
+import type { ReactNode } from "react";
 
 import styles from "./index.module.scss";
 
 type Props = {
-  query: string;
   onClearSearch?: (() => void) | undefined;
-};
+} & (
+  | { query: string }
+  | {
+      icon: ReactNode;
+      header: string;
+      body: string;
+      variant?: Variant | undefined;
+    }
+);
+
+type Variant = "success" | "error" | "warning" | "info" | "data";
 
-export const NoResults = ({ query, onClearSearch }: Props) => (
-  <div className={styles.noResults}>
-    <div className={styles.searchIcon}>
-      <MagnifyingGlass />
+export const NoResults = ({ onClearSearch, ...props }: Props) => (
+  <div
+    data-variant={"variant" in props ? (props.variant ?? "info") : "info"}
+    className={styles.noResults}
+  >
+    <div className={styles.icon}>
+      {"icon" in props ? props.icon : <MagnifyingGlass />}
     </div>
     <div className={styles.text}>
-      <h3 className={styles.header}>No results found</h3>
-      <p
-        className={styles.body}
-      >{`We couldn't find any results for "${query}".`}</p>
+      <h3 className={styles.header}>
+        {"header" in props ? props.header : "No results found"}
+      </h3>
+      <p className={styles.body}>
+        {"body" in props
+          ? props.body
+          : `We couldn't find any results for "${props.query}".`}
+      </p>
     </div>
     {onClearSearch && (
       <Button variant="outline" size="sm" onPress={onClearSearch}>

+ 20 - 0
apps/insights/src/components/PriceComponentDrawer/index.module.scss

@@ -0,0 +1,20 @@
+@use "@pythnetwork/component-library/theme";
+
+.priceComponentDrawer {
+  display: grid;
+  grid-template-rows: repeat(2, max-content);
+  grid-template-columns: 100%;
+  gap: theme.spacing(10);
+
+  .stats {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    grid-template-rows: repeat(2, 1fr);
+    gap: theme.spacing(4);
+  }
+
+  .spinner {
+    margin: theme.spacing(40) auto;
+    font-size: theme.spacing(16);
+  }
+}

+ 189 - 0
apps/insights/src/components/PriceComponentDrawer/index.tsx

@@ -0,0 +1,189 @@
+import { Button } from "@pythnetwork/component-library/Button";
+import { Drawer } from "@pythnetwork/component-library/Drawer";
+import { Spinner } from "@pythnetwork/component-library/Spinner";
+import { StatCard } from "@pythnetwork/component-library/StatCard";
+import { useRouter } from "next/navigation";
+import { type ReactNode, useState, useRef, useCallback } from "react";
+import { z } from "zod";
+
+import styles from "./index.module.scss";
+import { Cluster, ClusterToName } from "../../services/pyth";
+import type { Status } from "../../status";
+import { StateType, useData } from "../../use-data";
+import { LiveConfidence, LivePrice, LiveComponentValue } from "../LivePrices";
+import { Score } from "../Score";
+import { ScoreHistory as ScoreHistoryComponent } from "../ScoreHistory";
+import { Status as StatusComponent } from "../Status";
+
+type Props = {
+  onClose: () => void;
+  title: ReactNode;
+  headingExtra?: ReactNode | undefined;
+  publisherKey: string;
+  symbol: string;
+  feedKey: string;
+  score: number | undefined;
+  rank: number | undefined;
+  status: Status;
+  navigateButtonText: string;
+  navigateHref: string;
+};
+
+export const PriceComponentDrawer = ({
+  publisherKey,
+  onClose,
+  symbol,
+  feedKey,
+  score,
+  rank,
+  title,
+  status,
+  headingExtra,
+  navigateButtonText,
+  navigateHref,
+}: Props) => {
+  const goToPriceFeedPageOnClose = useRef<boolean>(false);
+  const [isFeedDrawerOpen, setIsFeedDrawerOpen] = useState(true);
+  const router = useRouter();
+  const handleClose = useCallback(
+    (isOpen: boolean) => {
+      if (!isOpen) {
+        setIsFeedDrawerOpen(false);
+      }
+    },
+    [setIsFeedDrawerOpen],
+  );
+  const handleCloseFinish = useCallback(() => {
+    if (goToPriceFeedPageOnClose.current) {
+      router.push(navigateHref);
+    } else {
+      onClose();
+    }
+  }, [router, onClose, navigateHref]);
+  const handleOpenFeed = useCallback(() => {
+    goToPriceFeedPageOnClose.current = true;
+    setIsFeedDrawerOpen(false);
+  }, [setIsFeedDrawerOpen]);
+  const scoreHistoryState = useData(
+    [Cluster.Pythnet, publisherKey, symbol],
+    getScoreHistory,
+  );
+
+  return (
+    <Drawer
+      onOpenChange={handleClose}
+      onCloseFinish={handleCloseFinish}
+      title={title}
+      headingExtra={
+        <>
+          {headingExtra}
+          <StatusComponent status={status} />
+          <Button
+            size="sm"
+            variant="outline"
+            onPress={handleOpenFeed}
+            href={navigateHref}
+          >
+            {navigateButtonText}
+          </Button>
+        </>
+      }
+      isOpen={isFeedDrawerOpen}
+      bodyClassName={styles.priceComponentDrawer}
+    >
+      <div className={styles.stats}>
+        <StatCard
+          nonInteractive
+          header="Aggregate Price"
+          stat={<LivePrice feedKey={feedKey} />}
+        />
+        <StatCard
+          nonInteractive
+          header="Publisher Price"
+          variant="primary"
+          stat={<LivePrice feedKey={feedKey} publisherKey={publisherKey} />}
+        />
+        <StatCard
+          nonInteractive
+          header="Publisher Confidence"
+          small
+          stat={
+            <LiveConfidence feedKey={feedKey} publisherKey={publisherKey} />
+          }
+        />
+        <StatCard
+          nonInteractive
+          header="Last Slot"
+          small
+          stat={
+            <LiveComponentValue
+              feedKey={feedKey}
+              publisherKey={publisherKey}
+              field="publishSlot"
+            />
+          }
+        />
+        <StatCard
+          nonInteractive
+          header="Score"
+          small
+          stat={score ? <Score fill score={score} /> : <></>}
+        />
+        <StatCard
+          nonInteractive
+          header="Quality Rank"
+          small
+          stat={rank ?? <></>}
+        />
+      </div>
+      <ScoreHistory state={scoreHistoryState} />
+    </Drawer>
+  );
+};
+
+const ScoreHistory = ({
+  state,
+}: {
+  state: ReturnType<typeof useData<z.infer<typeof scoreHistorySchema>>>;
+}) => {
+  switch (state.type) {
+    case StateType.Loading:
+    case StateType.Error:
+    case StateType.NotLoaded: {
+      return (
+        <Spinner
+          label="Loading score history"
+          isIndeterminate
+          className={styles.spinner ?? ""}
+        />
+      );
+    }
+
+    case StateType.Loaded: {
+      return <ScoreHistoryComponent scoreHistory={state.data} />;
+    }
+  }
+};
+
+const getScoreHistory = async ([cluster, publisherKey, symbol]: [
+  Cluster,
+  string,
+  string,
+]) => {
+  const url = new URL("/component-score-history", window.location.origin);
+  url.searchParams.set("cluster", ClusterToName[cluster]);
+  url.searchParams.set("publisherKey", publisherKey);
+  url.searchParams.set("symbol", symbol);
+  const data = await fetch(url);
+  return scoreHistorySchema.parse(await data.json());
+};
+
+const scoreHistorySchema = z.array(
+  z.strictObject({
+    time: z.string().transform((value) => new Date(value)),
+    score: z.number(),
+    uptimeScore: z.number(),
+    deviationScore: z.number(),
+    stalledScore: z.number(),
+  }),
+);

+ 5 - 2
apps/insights/src/components/PriceFeed/layout.tsx

@@ -14,7 +14,7 @@ import styles from "./layout.module.scss";
 import { PriceFeedSelect } from "./price-feed-select";
 import { ReferenceData } from "./reference-data";
 import { toHex } from "../../hex";
-import { getData } from "../../services/pyth";
+import { Cluster, getData } from "../../services/pyth";
 import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent";
 import { FeedKey } from "../FeedKey";
 import {
@@ -35,7 +35,10 @@ type Props = {
 };
 
 export const PriceFeedLayout = async ({ children, params }: Props) => {
-  const [{ slug }, data] = await Promise.all([params, getData()]);
+  const [{ slug }, data] = await Promise.all([
+    params,
+    getData(Cluster.Pythnet),
+  ]);
   const symbol = decodeURIComponent(slug);
   const feed = data.find((item) => item.symbol === symbol);
 

+ 165 - 85
apps/insights/src/components/PriceFeed/publishers-card.tsx

@@ -11,59 +11,77 @@ import {
   type SortDescriptor,
   Table,
 } from "@pythnetwork/component-library/Table";
-import { useQueryState, parseAsBoolean } from "nuqs";
+import { useQueryState, parseAsString, parseAsBoolean } from "nuqs";
 import { type ReactNode, Suspense, useMemo, useCallback } from "react";
 import { useFilter, useCollator } from "react-aria";
 
 import styles from "./publishers-card.module.scss";
+import { Cluster } from "../../services/pyth";
+import { Status as StatusType } from "../../status";
 import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
 import { FormattedNumber } from "../FormattedNumber";
 import { NoResults } from "../NoResults";
+import { PriceComponentDrawer } from "../PriceComponentDrawer";
 import { PublisherTag } from "../PublisherTag";
 import rootStyles from "../Root/index.module.scss";
 import { Score } from "../Score";
+import { Status as StatusComponent } from "../Status";
 
 const SCORE_WIDTH = 24;
 
 type Props = {
+  symbol: string;
+  feedKey: string;
   className?: string | undefined;
-  priceComponents: PriceComponent[];
+  publishers: Publisher[];
 };
 
-type PriceComponent = {
+type Publisher = {
   id: string;
-  score: number;
-  uptimeScore: number;
-  deviationPenalty: number | null;
-  deviationScore: number;
-  stalledPenalty: number;
-  stalledScore: number;
-  isTest: boolean;
+  publisherKey: string;
+  score: number | undefined;
+  uptimeScore: number | undefined;
+  deviationPenalty: number | undefined;
+  deviationScore: number | undefined;
+  stalledPenalty: number | undefined;
+  stalledScore: number | undefined;
+  rank: number | undefined;
+  cluster: Cluster;
+  status: StatusType;
 } & (
   | { name: string; icon: ReactNode }
   | { name?: undefined; icon?: undefined }
 );
 
-export const PublishersCard = ({ priceComponents, ...props }: Props) => (
-  <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
-    <ResolvedPriceComponentsCard priceComponents={priceComponents} {...props} />
+export const PublishersCard = ({ publishers, ...props }: Props) => (
+  <Suspense fallback={<PublishersCardContents isLoading {...props} />}>
+    <ResolvedPublishersCard publishers={publishers} {...props} />
   </Suspense>
 );
 
-const ResolvedPriceComponentsCard = ({ priceComponents, ...props }: Props) => {
+const ResolvedPublishersCard = ({
+  symbol,
+  feedKey,
+  publishers,
+  ...props
+}: Props) => {
+  const { handleClose, selectedPublisher, updateSelectedPublisherKey } =
+    usePublisherDrawer(publishers);
   const logger = useLogger();
-  const [includeTestComponents, setIncludeTestComponents] = useQueryState(
-    "includeTestComponents",
+  const [includeTestFeeds, setIncludeTestFeeds] = useQueryState(
+    "includeTestFeeds",
     parseAsBoolean.withDefault(false),
   );
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
-  const filteredPriceComponents = useMemo(
+  const filteredPublishers = useMemo(
     () =>
-      includeTestComponents
-        ? priceComponents
-        : priceComponents.filter((component) => !component.isTest),
-    [includeTestComponents, priceComponents],
+      includeTestFeeds
+        ? publishers
+        : publishers.filter(
+            (publisher) => publisher.cluster === Cluster.Pythnet,
+          ),
+    [includeTestFeeds, publishers],
   );
 
   const {
@@ -80,34 +98,27 @@ const ResolvedPriceComponentsCard = ({ priceComponents, ...props }: Props) => {
     numPages,
     mkPageLink,
   } = useQueryParamFilterPagination(
-    filteredPriceComponents,
-    (priceComponent, search) =>
-      filter.contains(priceComponent.id, search) ||
-      (priceComponent.name !== undefined &&
-        filter.contains(priceComponent.name, search)),
+    filteredPublishers,
+    (publisher, search) =>
+      filter.contains(publisher.publisherKey, search) ||
+      (publisher.name !== undefined && filter.contains(publisher.name, search)),
     (a, b, { column, direction }) => {
       switch (column) {
         case "score":
         case "uptimeScore":
         case "deviationScore":
         case "stalledScore":
-        case "stalledPenalty": {
-          return (
-            (direction === "descending" ? -1 : 1) * (a[column] - b[column])
-          );
-        }
-
+        case "stalledPenalty":
         case "deviationPenalty": {
-          if (a.deviationPenalty === null && b.deviationPenalty === null) {
+          if (a[column] === undefined && b[column] === undefined) {
             return 0;
-          } else if (a.deviationPenalty === null) {
+          } else if (a[column] === undefined) {
             return direction === "descending" ? 1 : -1;
-          } else if (b.deviationPenalty === null) {
+          } else if (b[column] === undefined) {
             return direction === "descending" ? -1 : 1;
           } else {
             return (
-              (direction === "descending" ? -1 : 1) *
-              (a.deviationPenalty - b.deviationPenalty)
+              (direction === "descending" ? -1 : 1) * (a[column] - b[column])
             );
           }
         }
@@ -115,12 +126,25 @@ const ResolvedPriceComponentsCard = ({ priceComponents, ...props }: Props) => {
         case "name": {
           return (
             (direction === "descending" ? -1 : 1) *
-            collator.compare(a.name ?? a.id, b.name ?? b.id)
+            collator.compare(a.name ?? a.publisherKey, b.name ?? b.publisherKey)
           );
         }
 
+        case "status": {
+          const resultByStatus = b.status - a.status;
+          const result =
+            resultByStatus === 0
+              ? collator.compare(
+                  a.name ?? a.publisherKey,
+                  b.name ?? b.publisherKey,
+                )
+              : resultByStatus;
+
+          return (direction === "descending" ? -1 : 1) * result;
+        }
+
         default: {
-          return (direction === "descending" ? -1 : 1) * (a.score - b.score);
+          return 0;
         }
       }
     },
@@ -136,105 +160,128 @@ const ResolvedPriceComponentsCard = ({ priceComponents, ...props }: Props) => {
       paginatedItems.map(
         ({
           id,
+          publisherKey,
           score,
           uptimeScore,
           deviationPenalty,
           deviationScore,
           stalledPenalty,
           stalledScore,
-          isTest,
+          cluster,
+          status,
           ...publisher
         }) => ({
           id,
+          onAction: () => {
+            updateSelectedPublisherKey(publisherKey);
+          },
           data: {
-            score: <Score score={score} width={SCORE_WIDTH} />,
+            score: score !== undefined && (
+              <Score score={score} width={SCORE_WIDTH} />
+            ),
             name: (
               <div className={styles.publisherName}>
                 <PublisherTag
-                  publisherKey={id}
+                  publisherKey={publisherKey}
                   {...(publisher.name && {
                     name: publisher.name,
                     icon: publisher.icon,
                   })}
                 />
-                {isTest && (
+                {cluster === Cluster.PythtestConformance && (
                   <Badge variant="muted" style="filled" size="xs">
                     test
                   </Badge>
                 )}
               </div>
             ),
-            uptimeScore: (
+            uptimeScore: uptimeScore && (
               <FormattedNumber
                 value={uptimeScore}
                 maximumSignificantDigits={5}
               />
             ),
-            deviationPenalty: deviationPenalty ? (
+            deviationPenalty: deviationPenalty && (
               <FormattedNumber
                 value={deviationPenalty}
                 maximumSignificantDigits={5}
               />
-            ) : // eslint-disable-next-line unicorn/no-null
-            null,
-            deviationScore: (
+            ),
+            deviationScore: deviationScore && (
               <FormattedNumber
                 value={deviationScore}
                 maximumSignificantDigits={5}
               />
             ),
-            stalledPenalty: (
+            stalledPenalty: stalledPenalty && (
               <FormattedNumber
                 value={stalledPenalty}
                 maximumSignificantDigits={5}
               />
             ),
-            stalledScore: (
+            stalledScore: stalledScore && (
               <FormattedNumber
                 value={stalledScore}
                 maximumSignificantDigits={5}
               />
             ),
+            status: <StatusComponent status={status} />,
           },
         }),
       ),
-    [paginatedItems],
+    [paginatedItems, updateSelectedPublisherKey],
   );
 
-  const updateIncludeTestComponents = useCallback(
+  const updateIncludeTestFeeds = useCallback(
     (newValue: boolean) => {
-      setIncludeTestComponents(newValue).catch((error: unknown) => {
+      setIncludeTestFeeds(newValue).catch((error: unknown) => {
         logger.error(
           "Failed to update include test components query param",
           error,
         );
       });
     },
-    [setIncludeTestComponents, logger],
+    [setIncludeTestFeeds, logger],
   );
 
   return (
-    <PriceComponentsCardContents
-      numResults={numResults}
-      search={search}
-      sortDescriptor={sortDescriptor}
-      numPages={numPages}
-      page={page}
-      pageSize={pageSize}
-      includeTestComponents={includeTestComponents}
-      onSearchChange={updateSearch}
-      onSortChange={updateSortDescriptor}
-      onPageSizeChange={updatePageSize}
-      onPageChange={updatePage}
-      mkPageLink={mkPageLink}
-      onIncludeTestComponentsChange={updateIncludeTestComponents}
-      rows={rows}
-      {...props}
-    />
+    <>
+      <PublishersCardContents
+        numResults={numResults}
+        search={search}
+        sortDescriptor={sortDescriptor}
+        numPages={numPages}
+        page={page}
+        pageSize={pageSize}
+        includeTestFeeds={includeTestFeeds}
+        onSearchChange={updateSearch}
+        onSortChange={updateSortDescriptor}
+        onPageSizeChange={updatePageSize}
+        onPageChange={updatePage}
+        mkPageLink={mkPageLink}
+        onIncludeTestFeedsChange={updateIncludeTestFeeds}
+        rows={rows}
+        {...props}
+      />
+      {selectedPublisher && (
+        <PriceComponentDrawer
+          publisherKey={selectedPublisher.publisherKey}
+          onClose={handleClose}
+          symbol={symbol}
+          feedKey={feedKey}
+          rank={selectedPublisher.rank}
+          score={selectedPublisher.score}
+          status={selectedPublisher.status}
+          title={<PublisherTag {...selectedPublisher} />}
+          navigateButtonText="Open Publisher"
+          navigateHref={`/publishers/${selectedPublisher.publisherKey}`}
+        />
+      )}
+    </>
   );
 };
 
-type PriceComponentsCardProps = Pick<Props, "className"> &
+type PublishersCardProps = Pick<Props, "className"> &
   (
     | { isLoading: true }
     | {
@@ -245,8 +292,8 @@ type PriceComponentsCardProps = Pick<Props, "className"> &
         numPages: number;
         page: number;
         pageSize: number;
-        includeTestComponents: boolean;
-        onIncludeTestComponentsChange: (newValue: boolean) => void;
+        includeTestFeeds: boolean;
+        onIncludeTestFeedsChange: (newValue: boolean) => void;
         onSearchChange: (newSearch: string) => void;
         onSortChange: (newSort: SortDescriptor) => void;
         onPageSizeChange: (newPageSize: number) => void;
@@ -260,14 +307,15 @@ type PriceComponentsCardProps = Pick<Props, "className"> &
           | "deviationPenalty"
           | "stalledScore"
           | "stalledPenalty"
+          | "status"
         >[];
       }
   );
 
-const PriceComponentsCardContents = ({
+const PublishersCardContents = ({
   className,
   ...props
-}: PriceComponentsCardProps) => (
+}: PublishersCardProps) => (
   <Card
     className={className}
     title="Publishers"
@@ -277,11 +325,11 @@ const PriceComponentsCardContents = ({
           {...(props.isLoading
             ? { isLoading: true }
             : {
-                isSelected: props.includeTestComponents,
-                onChange: props.onIncludeTestComponentsChange,
+                isSelected: props.includeTestFeeds,
+                onChange: props.onIncludeTestFeedsChange,
               })}
         >
-          Show test components
+          Show test feeds
         </Switch>
         <SearchInput
           size="sm"
@@ -331,40 +379,42 @@ const PriceComponentsCardContents = ({
           isRowHeader: true,
           loadingSkeleton: <PublisherTag isLoading />,
           allowsSorting: true,
+          fill: true,
         },
         {
           id: "uptimeScore",
           name: "UPTIME SCORE",
           alignment: "center",
-          width: 35,
           allowsSorting: true,
         },
         {
           id: "deviationScore",
           name: "DEVIATION SCORE",
           alignment: "center",
-          width: 35,
           allowsSorting: true,
         },
         {
           id: "deviationPenalty",
           name: "DEVIATION PENALTY",
           alignment: "center",
-          width: 35,
           allowsSorting: true,
         },
         {
           id: "stalledScore",
           name: "STALLED SCORE",
           alignment: "center",
-          width: 35,
           allowsSorting: true,
         },
         {
           id: "stalledPenalty",
           name: "STALLED PENALTY",
           alignment: "center",
-          width: 35,
+          allowsSorting: true,
+        },
+        {
+          id: "status",
+          name: "STATUS",
+          alignment: "right",
           allowsSorting: true,
         },
       ]}
@@ -374,7 +424,7 @@ const PriceComponentsCardContents = ({
             rows: props.rows,
             sortDescriptor: props.sortDescriptor,
             onSortChange: props.onSortChange,
-            renderEmptyState: () => (
+            emptyState: (
               <NoResults
                 query={props.search}
                 onClearSearch={() => {
@@ -386,3 +436,33 @@ const PriceComponentsCardContents = ({
     />
   </Card>
 );
+
+const usePublisherDrawer = (publishers: Publisher[]) => {
+  const logger = useLogger();
+  const [selectedPublisherKey, setSelectedPublisher] = useQueryState(
+    "publisher",
+    parseAsString.withDefault("").withOptions({
+      history: "push",
+    }),
+  );
+  const updateSelectedPublisherKey = useCallback(
+    (newPublisherKey: string) => {
+      setSelectedPublisher(newPublisherKey).catch((error: unknown) => {
+        logger.error("Failed to update selected publisher", error);
+      });
+    },
+    [setSelectedPublisher, logger],
+  );
+  const selectedPublisher = useMemo(
+    () =>
+      publishers.find(
+        (publisher) => publisher.publisherKey === selectedPublisherKey,
+      ),
+    [selectedPublisherKey, publishers],
+  );
+  const handleClose = useCallback(() => {
+    updateSelectedPublisherKey("");
+  }, [updateSelectedPublisherKey]);
+
+  return { selectedPublisher, handleClose, updateSelectedPublisherKey };
+};

+ 59 - 22
apps/insights/src/components/PriceFeed/publishers.tsx

@@ -2,8 +2,9 @@ import { lookup as lookupPublisher } from "@pythnetwork/known-publishers";
 import { notFound } from "next/navigation";
 
 import { PublishersCard } from "./publishers-card";
-import { getRankings } from "../../services/clickhouse";
-import { getData } from "../../services/pyth";
+import { getRankingsBySymbol } from "../../services/clickhouse";
+import { Cluster, ClusterToName, getData } from "../../services/pyth";
+import { getStatus } from "../../status";
 import { PublisherIcon } from "../PublisherIcon";
 
 type Props = {
@@ -15,30 +16,66 @@ type Props = {
 export const Publishers = async ({ params }: Props) => {
   const { slug } = await params;
   const symbol = decodeURIComponent(slug);
-  const [data, rankings] = await Promise.all([getData(), getRankings(symbol)]);
-  const feed = data.find((feed) => feed.symbol === symbol);
+  const [pythnetData, pythnetPublishers, pythtestConformancePublishers] =
+    await Promise.all([
+      getData(Cluster.Pythnet),
+      getPublishers(Cluster.Pythnet, symbol),
+      getPublishers(Cluster.PythtestConformance, symbol),
+    ]);
+  const feed = pythnetData.find((item) => item.symbol === symbol);
 
-  return feed ? (
+  return feed !== undefined &&
+    (pythnetPublishers !== undefined ||
+      pythtestConformancePublishers !== undefined) ? (
     <PublishersCard
-      priceComponents={rankings.map((ranking) => {
-        const knownPublisher = lookupPublisher(ranking.publisher);
-        return {
-          id: ranking.publisher,
-          score: ranking.final_score,
-          isTest: ranking.cluster === "pythtest-conformance",
-          uptimeScore: ranking.uptime_score,
-          deviationPenalty: ranking.deviation_penalty,
-          deviationScore: ranking.deviation_score,
-          stalledPenalty: ranking.stalled_penalty,
-          stalledScore: ranking.stalled_score,
-          ...(knownPublisher && {
-            name: knownPublisher.name,
-            icon: <PublisherIcon knownPublisher={knownPublisher} />,
-          }),
-        };
-      })}
+      symbol={symbol}
+      feedKey={feed.product.price_account}
+      publishers={[
+        ...(pythnetPublishers ?? []),
+        ...(pythtestConformancePublishers ?? []),
+      ]}
     />
   ) : (
     notFound()
   );
 };
+
+const getPublishers = async (cluster: Cluster, symbol: string) => {
+  const [data, rankings] = await Promise.all([
+    getData(cluster),
+    getRankingsBySymbol(symbol),
+  ]);
+
+  return data
+    .find((feed) => feed.symbol === symbol)
+    ?.price.priceComponents.map(({ publisher }) => {
+      const ranking = rankings.find(
+        (ranking) =>
+          ranking.publisher === publisher &&
+          ranking.cluster === ClusterToName[cluster],
+      );
+
+      //if (!ranking) {
+      //  console.log(`No ranking for publisher: ${publisher} in cluster ${ClusterToName[cluster]}`);
+      //}
+
+      const knownPublisher = publisher ? lookupPublisher(publisher) : undefined;
+      return {
+        id: `${publisher}-${ClusterToName[Cluster.Pythnet]}`,
+        publisherKey: publisher,
+        score: ranking?.final_score,
+        uptimeScore: ranking?.uptime_score,
+        deviationPenalty: ranking?.deviation_penalty ?? undefined,
+        deviationScore: ranking?.deviation_score,
+        stalledPenalty: ranking?.stalled_penalty,
+        stalledScore: ranking?.stalled_score,
+        rank: ranking?.final_rank,
+        cluster,
+        status: getStatus(ranking),
+        ...(knownPublisher && {
+          name: knownPublisher.name,
+          icon: <PublisherIcon knownPublisher={knownPublisher} />,
+        }),
+      };
+    });
+};

+ 8 - 6
apps/insights/src/components/PriceFeedTag/index.module.scss

@@ -5,7 +5,6 @@
   flex-flow: row nowrap;
   gap: theme.spacing(3);
   align-items: center;
-  width: 100%;
 
   .icon {
     flex: none;
@@ -16,7 +15,7 @@
   .nameAndDescription {
     display: flex;
     flex-flow: column nowrap;
-    gap: theme.spacing(1);
+    gap: theme.spacing(1.5);
     flex-grow: 1;
     flex-basis: 0;
     white-space: nowrap;
@@ -31,13 +30,17 @@
       gap: theme.spacing(1);
       color: theme.color("heading");
 
+      @include theme.text("base", "normal");
+
+      line-height: theme.spacing(4);
+
       .firstPart {
         font-weight: theme.font-weight("medium");
       }
 
       .divider {
-        font-weight: theme.font-weight("light");
         color: theme.color("muted");
+        font-weight: theme.font-weight("light");
       }
 
       .part {
@@ -46,12 +49,11 @@
     }
 
     .description {
-      font-size: theme.font-size("xs");
-      font-weight: theme.font-weight("medium");
-      line-height: theme.spacing(4);
       color: theme.color("muted");
       overflow: hidden;
       text-overflow: ellipsis;
+
+      @include theme.text("xs", "medium");
     }
   }
 

+ 2 - 2
apps/insights/src/components/PriceFeeds/coming-soon-list.tsx

@@ -106,14 +106,14 @@ export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
         stickyHeader
         label="Coming Soon"
         className={styles.priceFeeds ?? ""}
-        renderEmptyState={() => (
+        emptyState={
           <NoResults
             query={search}
             onClearSearch={() => {
               setSearch("");
             }}
           />
-        )}
+        }
         columns={[
           {
             id: "priceFeedName",

+ 2 - 2
apps/insights/src/components/PriceFeeds/index.tsx

@@ -17,7 +17,7 @@ import { AssetClassesDrawer } from "./asset-classes-drawer";
 import { ComingSoonList } from "./coming-soon-list";
 import styles from "./index.module.scss";
 import { PriceFeedsCard } from "./price-feeds-card";
-import { getData } from "../../services/pyth";
+import { Cluster, getData } from "../../services/pyth";
 import { priceFeeds as priceFeedsStaticConfig } from "../../static-data/price-feeds";
 import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent";
 import { LivePrice } from "../LivePrices";
@@ -196,7 +196,7 @@ const FeaturedFeedsCard = <T extends ElementType>({
 );
 
 const getPriceFeeds = async () => {
-  const priceFeeds = await getData();
+  const priceFeeds = await getData(Cluster.Pythnet);
   const activeFeeds = priceFeeds.filter((feed) => isActive(feed));
   const comingSoon = priceFeeds.filter((feed) => !isActive(feed));
   return { activeFeeds, comingSoon };

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

@@ -350,7 +350,7 @@ const PriceFeedsCardContents = ({ id, ...props }: PriceFeedsCardContents) => (
             rows: props.rows,
             sortDescriptor: props.sortDescriptor,
             onSortChange: props.onSortChange,
-            renderEmptyState: () => (
+            emptyState: (
               <NoResults
                 query={props.search}
                 onClearSearch={() => {

+ 31 - 0
apps/insights/src/components/Publisher/get-price-feeds.tsx

@@ -0,0 +1,31 @@
+import { getRankingsByPublisher } from "../../services/clickhouse";
+import { type Cluster, ClusterToName, getData } from "../../services/pyth";
+import { getStatus } from "../../status";
+
+export const getPriceFeeds = async (cluster: Cluster, key: string) => {
+  const [data, rankings] = await Promise.all([
+    getData(cluster),
+    getRankingsByPublisher(key),
+  ]);
+  return data
+    .filter((feed) =>
+      feed.price.priceComponents.some(
+        (component) => component.publisher === key,
+      ),
+    )
+    .map((feed) => {
+      const ranking = rankings.find(
+        (ranking) =>
+          ranking.symbol === feed.symbol &&
+          ranking.cluster === ClusterToName[cluster],
+      );
+      //if (!ranking) {
+      //  console.log(`No ranking for feed: ${feed.symbol} in cluster ${ClusterToName[cluster]}`);
+      //}
+      return {
+        ranking,
+        feed,
+        status: getStatus(ranking),
+      };
+    });
+};

+ 0 - 24
apps/insights/src/components/Publisher/get-rankings-with-data.ts

@@ -1,24 +0,0 @@
-import { getPublisherFeeds } from "../../services/clickhouse";
-import { getData } from "../../services/pyth";
-
-export const getRankingsWithData = async (key: string) => {
-  const [data, rankings] = await Promise.all([
-    getData(),
-    getPublisherFeeds(key),
-  ]);
-  const rankingsWithData = rankings.map((ranking) => {
-    const feed = data.find((feed) => feed.symbol === ranking.symbol);
-    if (!feed) {
-      throw new NoSuchFeedError(ranking.symbol);
-    }
-    return { ranking, feed };
-  });
-  return rankingsWithData;
-};
-
-class NoSuchFeedError extends Error {
-  constructor(symbol: string) {
-    super(`No feed exists named ${symbol}`);
-    this.name = "NoSuchFeedError";
-  }
-}

+ 293 - 276
apps/insights/src/components/Publisher/layout.tsx

@@ -17,26 +17,28 @@ import type { ReactNode } from "react";
 
 import { ActiveFeedsCard } from "./active-feeds-card";
 import { ChartCard } from "./chart-card";
+import { getPriceFeeds } from "./get-price-feeds";
 import styles from "./layout.module.scss";
-import { MedianScoreHistory } from "./median-score-history";
 import { OisApyHistory } from "./ois-apy-history";
+import { PriceFeedDrawerProvider } from "./price-feed-drawer-provider";
 import {
-  getPublishers,
-  getPublisherFeeds,
   getPublisherRankingHistory,
   getPublisherMedianScoreHistory,
 } from "../../services/clickhouse";
 import { getPublisherCaps } from "../../services/hermes";
-import { getTotalFeedCount } from "../../services/pyth";
+import { Cluster, getTotalFeedCount } from "../../services/pyth";
 import { getPublisherPoolData } from "../../services/staking";
+import { Status } from "../../status";
 import { ChangeValue } from "../ChangeValue";
 import { FormattedDate } from "../FormattedDate";
 import { FormattedNumber } from "../FormattedNumber";
 import { FormattedTokens } from "../FormattedTokens";
 import { Meter } from "../Meter";
+import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PublisherIcon } from "../PublisherIcon";
 import { PublisherKey } from "../PublisherKey";
 import { PublisherTag } from "../PublisherTag";
+import { ScoreHistory } from "../ScoreHistory";
 import { SemicircleMeter } from "../SemicircleMeter";
 import { TabPanel, TabRoot, Tabs } from "../Tabs";
 import { TokenIcon } from "../TokenIcon";
@@ -51,23 +53,19 @@ type Props = {
 export const PublishersLayout = async ({ children, params }: Props) => {
   const { key } = await params;
   const [
-    publishers,
     rankingHistory,
     medianScoreHistory,
     totalFeedsCount,
     oisStats,
-    publisherFeeds,
+    priceFeeds,
   ] = await Promise.all([
-    getPublishers(),
     getPublisherRankingHistory(key),
     getPublisherMedianScoreHistory(key),
-    getTotalFeedCount(),
+    getTotalFeedCount(Cluster.Pythnet),
     getOisStats(key),
-    getPublisherFeeds(key),
+    getPriceFeeds(Cluster.Pythnet, key),
   ]);
 
-  const publisher = publishers.find((publisher) => publisher.key === key);
-
   const currentRanking = rankingHistory.at(-1);
   const previousRanking = rankingHistory.at(-2);
 
@@ -75,307 +73,326 @@ export const PublishersLayout = async ({ children, params }: Props) => {
   const previousMedianScore = medianScoreHistory.at(-2);
   const knownPublisher = lookup(key);
 
-  return currentRanking && currentMedianScore && publisher ? (
-    <div className={styles.publisherLayout}>
-      <section className={styles.header}>
-        <div className={styles.headerRow}>
-          <Breadcrumbs
-            className={styles.breadcrumbs ?? ""}
-            label="Breadcrumbs"
-            items={[
-              { href: "/", label: "Home" },
-              { href: "/publishers", label: "Publishers" },
-              { label: <PublisherKey size="sm" publisherKey={key} /> },
-            ]}
-          />
-        </div>
-        <div className={styles.headerRow}>
-          <PublisherTag
-            publisherKey={key}
-            {...(knownPublisher && {
-              name: knownPublisher.name,
-              icon: <PublisherIcon knownPublisher={knownPublisher} />,
-            })}
-          />
-        </div>
-        <section className={styles.stats}>
-          <ChartCard
-            variant="primary"
-            header="Publisher Ranking"
-            lineClassName={styles.primarySparkChartLine}
-            corner={
-              <AlertTrigger>
-                <Button
-                  variant="ghost"
-                  size="xs"
-                  beforeIcon={(props) => <Info weight="fill" {...props} />}
-                  rounded
-                  hideText
-                  className={styles.publisherRankingExplainButton ?? ""}
-                >
-                  Explain Publisher Ranking
-                </Button>
-                <Alert title="Publisher Ranking" icon={<Lightbulb />}>
-                  <p className={styles.publisherRankingExplainDescription}>
-                    Each <b>Publisher</b> receives a <b>Ranking</b> which is
-                    derived from the number of price feeds the <b>Publisher</b>{" "}
-                    is actively publishing.
-                  </p>
-                </Alert>
-              </AlertTrigger>
-            }
-            data={rankingHistory.map(({ timestamp, rank }) => ({
-              x: timestamp,
-              y: rank,
-              displayX: (
-                <span className={styles.activeDate}>
-                  <FormattedDate value={timestamp} />
-                </span>
-              ),
-            }))}
-            stat={currentRanking.rank}
-            {...(previousRanking && {
-              miniStat: (
-                <ChangeValue
-                  direction={getChangeDirection(
-                    currentRanking.rank,
-                    previousRanking.rank,
-                  )}
-                >
-                  {Math.abs(currentRanking.rank - previousRanking.rank)}
-                </ChangeValue>
-              ),
-            })}
-          />
-          <DrawerTrigger>
+  return currentRanking && currentMedianScore ? (
+    <PriceFeedDrawerProvider
+      publisherKey={key}
+      priceFeeds={priceFeeds.map(({ feed, ranking, status }) => ({
+        symbol: feed.symbol,
+        displaySymbol: feed.product.display_symbol,
+        description: feed.product.description,
+        icon: <PriceFeedIcon symbol={feed.product.display_symbol} />,
+        feedKey: feed.product.price_account,
+        score: ranking?.final_score,
+        rank: ranking?.final_rank,
+        status,
+      }))}
+    >
+      <div className={styles.publisherLayout}>
+        <section className={styles.header}>
+          <div className={styles.headerRow}>
+            <Breadcrumbs
+              className={styles.breadcrumbs ?? ""}
+              label="Breadcrumbs"
+              items={[
+                { href: "/", label: "Home" },
+                { href: "/publishers", label: "Publishers" },
+                { label: <PublisherKey size="sm" publisherKey={key} /> },
+              ]}
+            />
+          </div>
+          <div className={styles.headerRow}>
+            <PublisherTag
+              publisherKey={key}
+              {...(knownPublisher && {
+                name: knownPublisher.name,
+                icon: <PublisherIcon knownPublisher={knownPublisher} />,
+              })}
+            />
+          </div>
+          <section className={styles.stats}>
             <ChartCard
-              header="Median Score"
-              chartClassName={styles.medianScoreChart}
-              lineClassName={styles.secondarySparkChartLine}
-              corner={<Info weight="fill" />}
-              data={medianScoreHistory.map(({ time, medianScore }) => ({
-                x: time,
-                y: medianScore,
+              variant="primary"
+              header="Publisher Ranking"
+              lineClassName={styles.primarySparkChartLine}
+              corner={
+                <AlertTrigger>
+                  <Button
+                    variant="ghost"
+                    size="xs"
+                    beforeIcon={(props) => <Info weight="fill" {...props} />}
+                    rounded
+                    hideText
+                    className={styles.publisherRankingExplainButton ?? ""}
+                  >
+                    Explain Publisher Ranking
+                  </Button>
+                  <Alert title="Publisher Ranking" icon={<Lightbulb />}>
+                    <p className={styles.publisherRankingExplainDescription}>
+                      Each <b>Publisher</b> receives a <b>Ranking</b> which is
+                      derived from the number of price feeds the{" "}
+                      <b>Publisher</b> is actively publishing.
+                    </p>
+                  </Alert>
+                </AlertTrigger>
+              }
+              data={rankingHistory.map(({ timestamp, rank }) => ({
+                x: timestamp,
+                y: rank,
                 displayX: (
                   <span className={styles.activeDate}>
-                    <FormattedDate value={time} />
+                    <FormattedDate value={timestamp} />
                   </span>
                 ),
-                displayY: (
-                  <FormattedNumber
-                    maximumSignificantDigits={5}
-                    value={medianScore}
-                  />
-                ),
               }))}
-              stat={
-                <FormattedNumber
-                  maximumSignificantDigits={5}
-                  value={currentMedianScore.medianScore}
-                />
-              }
-              {...(previousMedianScore && {
+              stat={currentRanking.rank}
+              {...(previousRanking && {
                 miniStat: (
                   <ChangeValue
                     direction={getChangeDirection(
-                      previousMedianScore.medianScore,
-                      currentMedianScore.medianScore,
+                      currentRanking.rank,
+                      previousRanking.rank,
                     )}
                   >
-                    <FormattedNumber
-                      maximumSignificantDigits={2}
-                      value={
-                        (100 *
-                          Math.abs(
-                            currentMedianScore.medianScore -
-                              previousMedianScore.medianScore,
-                          )) /
-                        previousMedianScore.medianScore
-                      }
-                    />
-                    %
+                    {Math.abs(currentRanking.rank - previousRanking.rank)}
                   </ChangeValue>
                 ),
               })}
             />
-            <Drawer
-              title="Median Score"
-              className={styles.medianScoreDrawer ?? ""}
-              bodyClassName={styles.medianScoreDrawerBody}
-              footerClassName={styles.medianScoreDrawerFooter}
-              footer={
-                <Button
-                  variant="outline"
-                  size="sm"
-                  href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking"
-                  target="_blank"
-                  beforeIcon={BookOpenText}
-                >
-                  Documentation
-                </Button>
-              }
-            >
-              <MedianScoreHistory medianScoreHistory={medianScoreHistory} />
-              <InfoBox icon={<Ranking />} header="Publisher Score">
-                Each price feed a publisher provides has an associated score,
-                which is determined by the component{"'"}s uptime, price
-                deviation, and staleness. This panel shows the median for each
-                score across all price feeds published by this publisher, as
-                well as the overall median score across all those feeds.
-              </InfoBox>
-            </Drawer>
-          </DrawerTrigger>
-          <ActiveFeedsCard
-            publisherKey={key}
-            activeFeeds={publisher.numSymbols}
-            totalFeeds={totalFeedsCount}
-          />
-          <DrawerTrigger>
-            <StatCard
-              header="OIS Pool Allocation"
-              stat={
-                <span
-                  className={styles.oisAllocation}
-                  data-is-overallocated={
-                    Number(oisStats.poolUtilization) > oisStats.maxPoolSize
-                      ? ""
-                      : undefined
-                  }
-                >
+            <DrawerTrigger>
+              <ChartCard
+                header="Median Score"
+                chartClassName={styles.medianScoreChart}
+                lineClassName={styles.secondarySparkChartLine}
+                corner={<Info weight="fill" />}
+                data={medianScoreHistory.map(({ time, score }) => ({
+                  x: time,
+                  y: score,
+                  displayX: (
+                    <span className={styles.activeDate}>
+                      <FormattedDate value={time} />
+                    </span>
+                  ),
+                  displayY: (
+                    <FormattedNumber
+                      maximumSignificantDigits={5}
+                      value={score}
+                    />
+                  ),
+                }))}
+                stat={
                   <FormattedNumber
-                    maximumFractionDigits={2}
-                    value={
-                      (100 * Number(oisStats.poolUtilization)) /
-                      oisStats.maxPoolSize
-                    }
+                    maximumSignificantDigits={5}
+                    value={currentMedianScore.score}
                   />
-                  %
-                </span>
-              }
-              corner={<Info weight="fill" />}
-            >
-              <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>
                 }
+                {...(previousMedianScore && {
+                  miniStat: (
+                    <ChangeValue
+                      direction={getChangeDirection(
+                        previousMedianScore.score,
+                        currentMedianScore.score,
+                      )}
+                    >
+                      <FormattedNumber
+                        maximumSignificantDigits={2}
+                        value={
+                          (100 *
+                            Math.abs(
+                              currentMedianScore.score -
+                                previousMedianScore.score,
+                            )) /
+                          previousMedianScore.score
+                        }
+                      />
+                      %
+                    </ChangeValue>
+                  ),
+                })}
               />
-            </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>
+              <Drawer
+                title="Median Score"
+                className={styles.medianScoreDrawer ?? ""}
+                bodyClassName={styles.medianScoreDrawerBody}
+                footerClassName={styles.medianScoreDrawerFooter}
+                footer={
                   <Button
                     variant="outline"
                     size="sm"
-                    href="https://docs.pyth.network/home/oracle-integrity-staking"
+                    href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking"
                     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"
+                }
               >
-                <TokenIcon className={styles.oisMeterIcon} />
-                <div className={styles.oisMeterLabel}>OIS Pool</div>
-              </SemicircleMeter>
+                <ScoreHistory isMedian scoreHistory={medianScoreHistory} />
+                <InfoBox icon={<Ranking />} header="Publisher Score">
+                  Each price feed a publisher provides has an associated score,
+                  which is determined by the component{"'"}s uptime, price
+                  deviation, and staleness. This panel shows the median for each
+                  score across all price feeds published by this publisher, as
+                  well as the overall median score across all those feeds.
+                </InfoBox>
+              </Drawer>
+            </DrawerTrigger>
+            <ActiveFeedsCard
+              publisherKey={key}
+              activeFeeds={
+                priceFeeds.filter((feed) => feed.status === Status.Active)
+                  .length
+              }
+              totalFeeds={totalFeedsCount}
+            />
+            <DrawerTrigger>
               <StatCard
-                header="Total Staked"
-                variant="secondary"
-                nonInteractive
+                header="OIS Pool Allocation"
                 stat={
-                  <>
-                    <TokenIcon />
-                    <FormattedTokens tokens={oisStats.poolUtilization} />
-                  </>
+                  <span
+                    className={styles.oisAllocation}
+                    data-is-overallocated={
+                      Number(oisStats.poolUtilization) > oisStats.maxPoolSize
+                        ? ""
+                        : undefined
+                    }
+                  >
+                    <FormattedNumber
+                      maximumFractionDigits={2}
+                      value={
+                        (100 * Number(oisStats.poolUtilization)) /
+                        oisStats.maxPoolSize
+                      }
+                    />
+                    %
+                  </span>
                 }
-              />
-              <StatCard
-                header="Pool Capacity"
-                variant="secondary"
-                nonInteractive
-                stat={
+                corner={<Info weight="fill" />}
+              >
+                <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>
-      </section>
-      <TabRoot>
-        <Tabs
-          label="Price Feed Navigation"
-          prefix={`/publishers/${key}`}
-          items={[
-            { segment: undefined, children: "Performance" },
-            {
-              segment: "price-feeds",
-              children: (
-                <div className={styles.priceFeedsTabLabel}>
-                  <span>Price Feeds</span>
-                  <Badge size="xs" style="filled" variant="neutral">
-                    {publisherFeeds.length}
-                  </Badge>
-                </div>
-              ),
-            },
-          ]}
-        />
-        <TabPanel className={styles.body ?? ""}>{children}</TabPanel>
-      </TabRoot>
-    </div>
+        <TabRoot>
+          <Tabs
+            label="Price Feed Navigation"
+            prefix={`/publishers/${key}`}
+            items={[
+              { segment: undefined, children: "Performance" },
+              {
+                segment: "price-feeds",
+                children: (
+                  <div className={styles.priceFeedsTabLabel}>
+                    <span>Price Feeds</span>
+                    <Badge size="xs" style="filled" variant="neutral">
+                      {priceFeeds.length}
+                    </Badge>
+                  </div>
+                ),
+              },
+            ]}
+          />
+          <TabPanel className={styles.body ?? ""}>{children}</TabPanel>
+        </TabRoot>
+      </div>
+    </PriceFeedDrawerProvider>
   ) : (
     notFound()
   );

+ 69 - 57
apps/insights/src/components/Publisher/performance.tsx

@@ -1,15 +1,20 @@
 import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
+import { Confetti } from "@phosphor-icons/react/dist/ssr/Confetti";
 import { Network } from "@phosphor-icons/react/dist/ssr/Network";
+import { SmileySad } from "@phosphor-icons/react/dist/ssr/SmileySad";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Card } from "@pythnetwork/component-library/Card";
 import { Table } from "@pythnetwork/component-library/Table";
 import { lookup } from "@pythnetwork/known-publishers";
 import { notFound } from "next/navigation";
 
-import { getRankingsWithData } from "./get-rankings-with-data";
+import { getPriceFeeds } from "./get-price-feeds";
 import styles from "./performance.module.scss";
+import { TopFeedsTable } from "./top-feeds-table";
 import { getPublishers } from "../../services/clickhouse";
-import { getTotalFeedCount } from "../../services/pyth";
+import { Cluster, getTotalFeedCount } from "../../services/pyth";
+import { Status } from "../../status";
+import { NoResults } from "../NoResults";
 import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { PublisherIcon } from "../PublisherIcon";
@@ -27,10 +32,10 @@ type Props = {
 
 export const Performance = async ({ params }: Props) => {
   const { key } = await params;
-  const [publishers, rankingsWithData, totalFeeds] = await Promise.all([
+  const [publishers, priceFeeds, totalFeeds] = await Promise.all([
     getPublishers(),
-    getRankingsWithData(key),
-    getTotalFeedCount(),
+    getPriceFeeds(Cluster.Pythnet, key),
+    getTotalFeedCount(Cluster.Pythnet),
   ]);
   const slicedPublishers = sliceAround(
     publishers,
@@ -114,26 +119,40 @@ export const Performance = async ({ params }: Props) => {
         />
       </Card>
       <Card icon={<Network />} title="High-Performing Feeds">
-        <Table
-          rounded
-          fill
+        <TopFeedsTable
           label="High-Performing Feeds"
-          columns={feedColumns}
+          publisherScoreWidth={PUBLISHER_SCORE_WIDTH}
+          emptyState={
+            <NoResults
+              icon={<SmileySad />}
+              header="Oh no!"
+              body="This publisher has no high performing feeds"
+              variant="error"
+            />
+          }
           rows={getFeedRows(
-            rankingsWithData
+            priceFeeds
+              .filter((feed) => hasRanking(feed))
               .filter(({ ranking }) => ranking.final_score > 0.9)
               .sort((a, b) => b.ranking.final_score - a.ranking.final_score),
           )}
         />
       </Card>
       <Card icon={<Network />} title="Low-Performing Feeds">
-        <Table
-          rounded
-          fill
+        <TopFeedsTable
           label="Low-Performing Feeds"
-          columns={feedColumns}
+          publisherScoreWidth={PUBLISHER_SCORE_WIDTH}
+          emptyState={
+            <NoResults
+              icon={<Confetti />}
+              header="Looking good!"
+              body="This publisher has no low performing feeds"
+              variant="success"
+            />
+          }
           rows={getFeedRows(
-            rankingsWithData
+            priceFeeds
+              .filter((feed) => hasRanking(feed))
               .filter(({ ranking }) => ranking.final_score < 0.7)
               .sort((a, b) => a.ranking.final_score - b.ranking.final_score),
           )}
@@ -143,50 +162,39 @@ export const Performance = async ({ params }: Props) => {
   );
 };
 
-const feedColumns = [
-  {
-    id: "score" as const,
-    name: "SCORE",
-    alignment: "left" as const,
-    width: PUBLISHER_SCORE_WIDTH,
-  },
-  {
-    id: "asset" as const,
-    name: "ASSET",
-    isRowHeader: true,
-    alignment: "left" as const,
-  },
-  {
-    id: "assetClass" as const,
-    name: "ASSET CLASS",
-    alignment: "right" as const,
-    width: 40,
-  },
-];
-
 const getFeedRows = (
-  rankingsWithData: Awaited<ReturnType<typeof getRankingsWithData>>,
+  priceFeeds: (Omit<
+    Awaited<ReturnType<typeof getPriceFeeds>>,
+    "ranking"
+  >[number] & {
+    ranking: NonNullable<
+      Awaited<ReturnType<typeof getPriceFeeds>>[number]["ranking"]
+    >;
+  })[],
 ) =>
-  rankingsWithData.slice(0, 10).map(({ feed, ranking }) => ({
-    id: ranking.symbol,
-    data: {
-      asset: (
-        <PriceFeedTag
-          compact
-          symbol={feed.product.display_symbol}
-          icon={<PriceFeedIcon symbol={feed.symbol} />}
-        />
-      ),
-      assetClass: (
-        <Badge variant="neutral" style="outline" size="xs">
-          {feed.product.asset_type.toUpperCase()}
-        </Badge>
-      ),
-      score: (
-        <Score width={PUBLISHER_SCORE_WIDTH} score={ranking.final_score} />
-      ),
-    },
-  }));
+  priceFeeds
+    .filter((feed) => feed.status === Status.Active)
+    .slice(0, 20)
+    .map(({ feed, ranking }) => ({
+      id: ranking.symbol,
+      data: {
+        asset: (
+          <PriceFeedTag
+            compact
+            symbol={feed.product.display_symbol}
+            icon={<PriceFeedIcon symbol={feed.symbol} />}
+          />
+        ),
+        assetClass: (
+          <Badge variant="neutral" style="outline" size="xs">
+            {feed.product.asset_type.toUpperCase()}
+          </Badge>
+        ),
+        score: (
+          <Score width={PUBLISHER_SCORE_WIDTH} score={ranking.final_score} />
+        ),
+      },
+    }));
 
 const sliceAround = <T,>(
   arr: T[],
@@ -205,3 +213,7 @@ const sliceAround = <T,>(
     return arr.slice(min, max);
   }
 };
+
+const hasRanking = <T,>(feed: {
+  ranking: T | undefined;
+}): feed is { ranking: T } => feed.ranking !== undefined;

+ 109 - 0
apps/insights/src/components/Publisher/price-feed-drawer-provider.tsx

@@ -0,0 +1,109 @@
+"use client";
+
+import { useLogger } from "@pythnetwork/app-logger";
+import { parseAsString, useQueryState } from "nuqs";
+import {
+  type ReactNode,
+  type ComponentProps,
+  Suspense,
+  createContext,
+  useMemo,
+  useCallback,
+  use,
+} from "react";
+
+import type { Status } from "../../status";
+import { PriceComponentDrawer } from "../PriceComponentDrawer";
+import { PriceFeedTag } from "../PriceFeedTag";
+
+const PriceFeedDrawerContext = createContext<
+  ((symbol: string) => void) | undefined
+>(undefined);
+
+type PriceFeedDrawerProviderProps = Omit<
+  ComponentProps<typeof PriceFeedDrawerContext>,
+  "value"
+> & {
+  publisherKey: string;
+  priceFeeds: PriceFeeds[];
+};
+
+type PriceFeeds = {
+  symbol: string;
+  displaySymbol: string;
+  description: string;
+  icon: ReactNode;
+  feedKey: string;
+  score: number | undefined;
+  rank: number | undefined;
+  status: Status;
+};
+
+export const PriceFeedDrawerProvider = (
+  props: PriceFeedDrawerProviderProps,
+) => (
+  <Suspense fallback={props.children}>
+    <PriceFeedDrawerProviderImpl {...props} />
+  </Suspense>
+);
+
+const PriceFeedDrawerProviderImpl = ({
+  publisherKey,
+  priceFeeds,
+  children,
+}: PriceFeedDrawerProviderProps) => {
+  const logger = useLogger();
+  const [selectedSymbol, setSelectedSymbol] = useQueryState(
+    "price-feed",
+    parseAsString.withDefault("").withOptions({
+      history: "push",
+    }),
+  );
+  const updateSelectedSymbol = useCallback(
+    (newSymbol: string) => {
+      setSelectedSymbol(newSymbol).catch((error: unknown) => {
+        logger.error("Failed to update selected symbol", error);
+      });
+    },
+    [setSelectedSymbol, logger],
+  );
+  const selectedFeed = useMemo(
+    () => priceFeeds.find((feed) => feed.symbol === selectedSymbol),
+    [selectedSymbol, priceFeeds],
+  );
+  const handleClose = useCallback(() => {
+    updateSelectedSymbol("");
+  }, [updateSelectedSymbol]);
+  const feedHref = useMemo(
+    () => `/price-feeds/${encodeURIComponent(selectedFeed?.symbol ?? "")}`,
+    [selectedFeed],
+  );
+
+  return (
+    <PriceFeedDrawerContext value={updateSelectedSymbol}>
+      {children}
+      {selectedFeed && (
+        <PriceComponentDrawer
+          publisherKey={publisherKey}
+          onClose={handleClose}
+          feedKey={selectedFeed.feedKey}
+          rank={selectedFeed.rank}
+          score={selectedFeed.score}
+          symbol={selectedFeed.symbol}
+          status={selectedFeed.status}
+          navigateButtonText="Open Feed"
+          navigateHref={feedHref}
+          title={
+            <PriceFeedTag
+              symbol={selectedFeed.displaySymbol}
+              description={selectedFeed.description}
+              icon={selectedFeed.icon}
+            />
+          }
+        />
+      )}
+    </PriceFeedDrawerContext>
+  );
+};
+
+export const useSelectPriceFeed = () => use(PriceFeedDrawerContext);

+ 8 - 0
apps/insights/src/components/Publisher/price-feeds-card.module.scss

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

+ 83 - 48
apps/insights/src/components/Publisher/price-feeds-card.tsx

@@ -1,5 +1,6 @@
 "use client";
 
+import { Badge } from "@pythnetwork/component-library/Badge";
 import { Card } from "@pythnetwork/component-library/Card";
 import { Paginator } from "@pythnetwork/component-library/Paginator";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
@@ -11,42 +12,51 @@ import {
 import { type ReactNode, Suspense, useMemo } from "react";
 import { useFilter, useCollator } from "react-aria";
 
+import { useSelectPriceFeed } from "./price-feed-drawer-provider";
+import styles from "./price-feeds-card.module.scss";
+import { Cluster } from "../../services/pyth";
+import { Status as StatusType } from "../../status";
 import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
 import { FormattedNumber } from "../FormattedNumber";
 import { NoResults } from "../NoResults";
 import { PriceFeedTag } from "../PriceFeedTag";
 import rootStyles from "../Root/index.module.scss";
 import { Score } from "../Score";
+import { Status as StatusComponent } from "../Status";
 
 const SCORE_WIDTH = 24;
 
 type Props = {
   className?: string | undefined;
   toolbar?: ReactNode;
-  priceComponents: PriceComponent[];
+  priceFeeds: PriceFeed[];
 };
 
-type PriceComponent = {
+type PriceFeed = {
   id: string;
-  score: number;
+  score: number | undefined;
+  symbol: string;
   displaySymbol: string;
-  uptimeScore: number;
-  deviationPenalty: number | null;
-  deviationScore: number;
-  stalledPenalty: number;
-  stalledScore: number;
+  uptimeScore: number | undefined;
+  deviationPenalty: number | undefined;
+  deviationScore: number | undefined;
+  stalledPenalty: number | undefined;
+  stalledScore: number | undefined;
   icon: ReactNode;
+  cluster: Cluster;
+  status: StatusType;
 };
 
-export const PriceFeedsCard = ({ priceComponents, ...props }: Props) => (
-  <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
-    <ResolvedPriceComponentsCard priceComponents={priceComponents} {...props} />
+export const PriceFeedsCard = ({ priceFeeds, ...props }: Props) => (
+  <Suspense fallback={<PriceFeedsCardContents isLoading {...props} />}>
+    <ResolvedPriceFeedsCard priceFeeds={priceFeeds} {...props} />
   </Suspense>
 );
 
-const ResolvedPriceComponentsCard = ({ priceComponents, ...props }: Props) => {
+const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const selectPriceFeed = useSelectPriceFeed();
 
   const {
     search,
@@ -62,32 +72,25 @@ const ResolvedPriceComponentsCard = ({ priceComponents, ...props }: Props) => {
     numPages,
     mkPageLink,
   } = useQueryParamFilterPagination(
-    priceComponents,
-    (priceComponent, search) =>
-      filter.contains(priceComponent.displaySymbol, search),
+    priceFeeds,
+    (priceFeed, search) => filter.contains(priceFeed.displaySymbol, search),
     (a, b, { column, direction }) => {
       switch (column) {
         case "score":
         case "uptimeScore":
         case "deviationScore":
         case "stalledScore":
-        case "stalledPenalty": {
-          return (
-            (direction === "descending" ? -1 : 1) * (a[column] - b[column])
-          );
-        }
-
+        case "stalledPenalty":
         case "deviationPenalty": {
-          if (a.deviationPenalty === null && b.deviationPenalty === null) {
+          if (a[column] === undefined && b[column] === undefined) {
             return 0;
-          } else if (a.deviationPenalty === null) {
+          } else if (a[column] === undefined) {
             return direction === "descending" ? 1 : -1;
-          } else if (b.deviationPenalty === null) {
+          } else if (b[column] === undefined) {
             return direction === "descending" ? -1 : 1;
           } else {
             return (
-              (direction === "descending" ? -1 : 1) *
-              (a.deviationPenalty - b.deviationPenalty)
+              (direction === "descending" ? -1 : 1) * (a[column] - b[column])
             );
           }
         }
@@ -99,8 +102,18 @@ const ResolvedPriceComponentsCard = ({ priceComponents, ...props }: Props) => {
           );
         }
 
+        case "status": {
+          const resultByStatus = b.status - a.status;
+          const result =
+            resultByStatus === 0
+              ? collator.compare(a.displaySymbol, b.displaySymbol)
+              : resultByStatus;
+
+          return (direction === "descending" ? -1 : 1) * result;
+        }
+
         default: {
-          return (direction === "descending" ? -1 : 1) * (a.score - b.score);
+          return 0;
         }
       }
     },
@@ -123,51 +136,70 @@ const ResolvedPriceComponentsCard = ({ priceComponents, ...props }: Props) => {
           stalledPenalty,
           stalledScore,
           displaySymbol,
+          symbol,
           icon,
+          cluster,
+          status,
         }) => ({
           id,
           data: {
-            name: <PriceFeedTag compact symbol={displaySymbol} icon={icon} />,
-            score: <Score score={score} width={SCORE_WIDTH} />,
-            uptimeScore: (
+            name: (
+              <div className={styles.priceFeedName}>
+                <PriceFeedTag compact symbol={displaySymbol} icon={icon} />
+                {cluster === Cluster.PythtestConformance && (
+                  <Badge variant="muted" style="filled" size="xs">
+                    test
+                  </Badge>
+                )}
+              </div>
+            ),
+            score: score !== undefined && (
+              <Score score={score} width={SCORE_WIDTH} />
+            ),
+            uptimeScore: uptimeScore !== undefined && (
               <FormattedNumber
                 value={uptimeScore}
                 maximumSignificantDigits={5}
               />
             ),
-            deviationPenalty: deviationPenalty ? (
+            deviationPenalty: deviationPenalty !== undefined && (
               <FormattedNumber
                 value={deviationPenalty}
                 maximumSignificantDigits={5}
               />
-            ) : // eslint-disable-next-line unicorn/no-null
-            null,
-            deviationScore: (
+            ),
+            deviationScore: deviationScore !== undefined && (
               <FormattedNumber
                 value={deviationScore}
                 maximumSignificantDigits={5}
               />
             ),
-            stalledPenalty: (
+            stalledPenalty: stalledPenalty !== undefined && (
               <FormattedNumber
                 value={stalledPenalty}
                 maximumSignificantDigits={5}
               />
             ),
-            stalledScore: (
+            stalledScore: stalledScore !== undefined && (
               <FormattedNumber
                 value={stalledScore}
                 maximumSignificantDigits={5}
               />
             ),
+            status: <StatusComponent status={status} />,
           },
+          ...(selectPriceFeed && {
+            onAction: () => {
+              selectPriceFeed(symbol);
+            },
+          }),
         }),
       ),
-    [paginatedItems],
+    [paginatedItems, selectPriceFeed],
   );
 
   return (
-    <PriceComponentsCardContents
+    <PriceFeedsCardContents
       numResults={numResults}
       search={search}
       sortDescriptor={sortDescriptor}
@@ -185,7 +217,7 @@ const ResolvedPriceComponentsCard = ({ priceComponents, ...props }: Props) => {
   );
 };
 
-type PriceComponentsCardProps = Pick<Props, "className" | "toolbar"> &
+type PriceFeedsCardProps = Pick<Props, "className" | "toolbar"> &
   (
     | { isLoading: true }
     | {
@@ -209,14 +241,15 @@ type PriceComponentsCardProps = Pick<Props, "className" | "toolbar"> &
           | "deviationPenalty"
           | "stalledScore"
           | "stalledPenalty"
+          | "status"
         >[];
       }
   );
 
-const PriceComponentsCardContents = ({
+const PriceFeedsCardContents = ({
   className,
   ...props
-}: PriceComponentsCardProps) => (
+}: PriceFeedsCardProps) => (
   <Card
     className={className}
     title="Price Feeds"
@@ -256,7 +289,7 @@ const PriceComponentsCardContents = ({
         {
           id: "score",
           name: "SCORE",
-          alignment: "center",
+          alignment: "left",
           width: SCORE_WIDTH,
           loadingSkeleton: <Score isLoading width={SCORE_WIDTH} />,
           allowsSorting: true,
@@ -267,41 +300,43 @@ const PriceComponentsCardContents = ({
           alignment: "left",
           isRowHeader: true,
           loadingSkeleton: <PriceFeedTag compact isLoading />,
+          fill: true,
           allowsSorting: true,
         },
         {
           id: "uptimeScore",
           name: "UPTIME SCORE",
           alignment: "center",
-          width: 35,
           allowsSorting: true,
         },
         {
           id: "deviationScore",
           name: "DEVIATION SCORE",
           alignment: "center",
-          width: 35,
           allowsSorting: true,
         },
         {
           id: "deviationPenalty",
           name: "DEVIATION PENALTY",
           alignment: "center",
-          width: 35,
           allowsSorting: true,
         },
         {
           id: "stalledScore",
           name: "STALLED SCORE",
           alignment: "center",
-          width: 35,
           allowsSorting: true,
         },
         {
           id: "stalledPenalty",
           name: "STALLED PENALTY",
           alignment: "center",
-          width: 35,
+          allowsSorting: true,
+        },
+        {
+          id: "status",
+          name: "STATUS",
+          alignment: "right",
           allowsSorting: true,
         },
       ]}
@@ -311,7 +346,7 @@ const PriceComponentsCardContents = ({
             rows: props.rows,
             sortDescriptor: props.sortDescriptor,
             onSortChange: props.onSortChange,
-            renderEmptyState: () => (
+            emptyState: (
               <NoResults
                 query={props.search}
                 onClearSearch={() => {

+ 15 - 11
apps/insights/src/components/Publisher/price-feeds.tsx

@@ -1,5 +1,6 @@
-import { getRankingsWithData } from "./get-rankings-with-data";
+import { getPriceFeeds } from "./get-price-feeds";
 import { PriceFeedsCard } from "./price-feeds-card";
+import { Cluster, ClusterToName } from "../../services/pyth";
 import { PriceFeedIcon } from "../PriceFeedIcon";
 
 type Props = {
@@ -10,20 +11,23 @@ type Props = {
 
 export const PriceFeeds = async ({ params }: Props) => {
   const { key } = await params;
-  const rankingsWithData = await getRankingsWithData(key);
+  const feeds = await getPriceFeeds(Cluster.Pythnet, key);
 
   return (
     <PriceFeedsCard
-      priceComponents={rankingsWithData.map(({ ranking, feed }) => ({
-        id: feed.product.price_account,
+      priceFeeds={feeds.map(({ ranking, feed, status }) => ({
+        id: `${feed.product.price_account}-${ClusterToName[Cluster.Pythnet]}`,
+        symbol: feed.symbol,
         displaySymbol: feed.product.display_symbol,
-        score: ranking.final_score,
-        icon: <PriceFeedIcon symbol={feed.symbol} />,
-        uptimeScore: ranking.uptime_score,
-        deviationPenalty: ranking.deviation_penalty,
-        deviationScore: ranking.deviation_score,
-        stalledPenalty: ranking.stalled_penalty,
-        stalledScore: ranking.stalled_score,
+        score: ranking?.final_score,
+        icon: <PriceFeedIcon symbol={feed.product.display_symbol} />,
+        uptimeScore: ranking?.uptime_score,
+        deviationPenalty: ranking?.deviation_penalty ?? undefined,
+        deviationScore: ranking?.deviation_score,
+        stalledPenalty: ranking?.stalled_penalty,
+        stalledScore: ranking?.stalled_score,
+        cluster: Cluster.Pythnet,
+        status,
       }))}
     />
   );

+ 64 - 0
apps/insights/src/components/Publisher/top-feeds-table.tsx

@@ -0,0 +1,64 @@
+"use client";
+
+import { type RowConfig, Table } from "@pythnetwork/component-library/Table";
+import { type ReactNode, useMemo } from "react";
+
+import { useSelectPriceFeed } from "./price-feed-drawer-provider";
+
+type Props = {
+  publisherScoreWidth: number;
+  rows: RowConfig<"score" | "asset" | "assetClass">[];
+  emptyState: ReactNode;
+  label: string;
+};
+
+export const TopFeedsTable = ({
+  publisherScoreWidth,
+  rows,
+  ...props
+}: Props) => {
+  const selectPriceFeed = useSelectPriceFeed();
+
+  const rowsWithAction = useMemo(
+    () =>
+      rows.map((row) => ({
+        ...row,
+        ...(selectPriceFeed && {
+          onAction: () => {
+            selectPriceFeed(row.id.toString());
+          },
+        }),
+      })),
+    [selectPriceFeed, rows],
+  );
+
+  return (
+    <Table
+      rounded
+      fill
+      columns={[
+        {
+          id: "score",
+          name: "SCORE",
+          alignment: "left",
+          width: publisherScoreWidth,
+        },
+        {
+          id: "asset",
+          name: "ASSET",
+          isRowHeader: true,
+          alignment: "left",
+        },
+        {
+          id: "assetClass",
+          name: "ASSET CLASS",
+          alignment: "right",
+          width: 40,
+        },
+      ]}
+      hideHeadersInEmptyState
+      rows={rowsWithAction}
+      {...props}
+    />
+  );
+};

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

@@ -5,7 +5,6 @@
   flex-flow: row nowrap;
   gap: theme.spacing(3);
   align-items: center;
-  width: 100%;
 
   .icon,
   .undisclosedIconWrapper {
@@ -28,7 +27,8 @@
 
   .name {
     color: theme.color("heading");
-    font-weight: theme.font-weight("medium");
+
+    @include theme.text("base", "medium");
   }
 
   .publisherKey,

+ 2 - 2
apps/insights/src/components/Publishers/index.tsx

@@ -11,7 +11,7 @@ import styles from "./index.module.scss";
 import { PublishersCard } from "./publishers-card";
 import { getPublishers } from "../../services/clickhouse";
 import { getPublisherCaps } from "../../services/hermes";
-import { getData } from "../../services/pyth";
+import { Cluster, getData } from "../../services/pyth";
 import {
   getDelState,
   getClaimableRewards,
@@ -174,7 +174,7 @@ export const Publishers = async () => {
 };
 
 const getTotalFeedCount = async () => {
-  const pythData = await getData();
+  const pythData = await getData(Cluster.Pythnet);
   return pythData.filter(({ price }) => price.numComponentPrices > 0).length;
 };
 

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

@@ -270,7 +270,7 @@ const PublishersCardContents = ({
             rows: props.rows,
             sortDescriptor: props.sortDescriptor,
             onSortChange: props.onSortChange,
-            renderEmptyState: () => (
+            emptyState: (
               <NoResults
                 query={props.search}
                 onClearSearch={() => {

+ 7 - 4
apps/insights/src/components/Root/index.tsx

@@ -10,13 +10,13 @@ import styles from "./index.module.scss";
 import { SearchDialogProvider } from "./search-dialog";
 import { TabRoot, TabPanel } from "./tabs";
 import {
-  IS_PRODUCTION_SERVER,
+  ENABLE_ACCESSIBILITY_REPORTING,
   GOOGLE_ANALYTICS_ID,
   AMPLITUDE_API_KEY,
 } from "../../config/server";
 import { toHex } from "../../hex";
 import { getPublishers } from "../../services/clickhouse";
-import { getData } from "../../services/pyth";
+import { Cluster, getData } from "../../services/pyth";
 import { LivePricesProvider } from "../LivePrices";
 import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PublisherIcon } from "../PublisherIcon";
@@ -26,13 +26,16 @@ type Props = {
 };
 
 export const Root = async ({ children }: Props) => {
-  const [data, publishers] = await Promise.all([getData(), getPublishers()]);
+  const [data, publishers] = await Promise.all([
+    getData(Cluster.Pythnet),
+    getPublishers(),
+  ]);
 
   return (
     <BaseRoot
       amplitudeApiKey={AMPLITUDE_API_KEY}
       googleAnalyticsId={GOOGLE_ANALYTICS_ID}
-      enableAccessibilityReporting={!IS_PRODUCTION_SERVER}
+      enableAccessibilityReporting={ENABLE_ACCESSIBILITY_REPORTING}
       providers={[NuqsAdapter, LivePricesProvider]}
       className={styles.root}
     >

+ 10 - 8
apps/insights/src/components/Root/search-dialog.module.scss

@@ -19,19 +19,23 @@
     flex-flow: column nowrap;
     flex-grow: 1;
     gap: theme.spacing(1);
-    width: fit-content;
+    width: min-content;
 
-    .searchBar {
+    .searchBar,
+    .left {
       flex: none;
       display: flex;
       flex-flow: row nowrap;
-      gap: theme.spacing(2);
       align-items: center;
+    }
+
+    .searchBar {
+      justify-content: space-between;
       padding: theme.spacing(1);
+    }
 
-      .closeButton {
-        margin-left: theme.spacing(8);
-      }
+    .left {
+      gap: theme.spacing(2);
     }
 
     .body {
@@ -51,7 +55,6 @@
           display: flex;
           flex-flow: row nowrap;
           align-items: center;
-          width: 100%;
           cursor: pointer;
           transition: background-color 100ms linear;
           outline: none;
@@ -85,7 +88,6 @@
           }
 
           .itemType {
-            width: theme.spacing(21);
             flex-shrink: 0;
             margin-right: theme.spacing(6);
           }

+ 23 - 21
apps/insights/src/components/Root/search-dialog.tsx

@@ -154,25 +154,27 @@ export const SearchDialogProvider = ({
         aria-label="Search"
       >
         <div className={styles.searchBar}>
-          <SearchInput
-            size="md"
-            width={90}
-            placeholder="Asset symbol, publisher name or id"
-            value={search}
-            onChange={setSearch}
-            // eslint-disable-next-line jsx-a11y/no-autofocus
-            autoFocus
-          />
-          <SingleToggleGroup
-            selectedKeys={[type]}
-            // @ts-expect-error react-aria coerces everything to Key for some reason...
-            onSelectionChange={updateSelectedType}
-            items={[
-              { id: "", children: "All" },
-              { id: ResultType.PriceFeed, children: "Price Feeds" },
-              { id: ResultType.Publisher, children: "Publishers" },
-            ]}
-          />
+          <div className={styles.left}>
+            <SearchInput
+              size="md"
+              width={90}
+              placeholder="Asset symbol, publisher name or id"
+              value={search}
+              onChange={setSearch}
+              // eslint-disable-next-line jsx-a11y/no-autofocus
+              autoFocus
+            />
+            <SingleToggleGroup
+              selectedKeys={[type]}
+              // @ts-expect-error react-aria coerces everything to Key for some reason...
+              onSelectionChange={updateSelectedType}
+              items={[
+                { id: "", children: "All" },
+                { id: ResultType.PriceFeed, children: "Price Feeds" },
+                { id: ResultType.Publisher, children: "Publishers" },
+              ]}
+            />
+          </div>
           <Button
             className={styles.closeButton ?? ""}
             beforeIcon={(props) => <XCircle weight="fill" {...props} />}
@@ -197,14 +199,14 @@ export const SearchDialogProvider = ({
               // property in the typescript types correctly...
               shouldFocusOnHover
               onAction={close}
-              renderEmptyState={() => (
+              emptyState={
                 <NoResults
                   query={search}
                   onClearSearch={() => {
                     setSearch("");
                   }}
                 />
-              )}
+              }
             >
               {(result) => (
                 <ListBoxItem

+ 53 - 44
apps/insights/src/components/Score/index.module.scss

@@ -1,66 +1,75 @@
 @use "@pythnetwork/component-library/theme";
 
-.score {
+.meter {
+  line-height: 0;
   width: calc(theme.spacing(1) * var(--width));
-  height: theme.spacing(4);
-  border-radius: theme.border-radius("3xl");
-  position: relative;
-  display: inline-block;
 
-  .fill {
-    position: absolute;
-    top: 0;
-    bottom: 0;
-    left: 0;
+  .score {
+    height: theme.spacing(6);
     border-radius: theme.border-radius("3xl");
-    color: theme.color("background", "primary");
-    display: grid;
-    place-content: center;
-    text-shadow:
-      0 1px 2px rgb(0 0 0 / 10%),
-      0 1px 3px rgb(0 0 0 / 10%);
-    font-size: theme.font-size("xxs");
-    font-weight: theme.font-weight("semibold");
-    line-height: theme.spacing(4);
-  }
-
-  &[data-size-class="bad"] {
-    background: theme.color("states", "error", "background");
+    position: relative;
+    display: inline-block;
+    width: 100%;
 
     .fill {
-      background: theme.color("states", "error", "color");
+      position: absolute;
+      top: 0;
+      bottom: 0;
+      left: 0;
+      border-radius: theme.border-radius("3xl");
+      color: theme.color("background", "primary");
+      display: grid;
+      place-content: center;
+      text-shadow:
+        0 1px 2px rgb(0 0 0 / 10%),
+        0 1px 3px rgb(0 0 0 / 10%);
+      font-size: theme.font-size("xxs");
+      font-weight: theme.font-weight("semibold");
+      line-height: theme.spacing(6);
     }
-  }
 
-  &[data-size-class="weak"] {
-    background: theme.color("states", "warning", "background");
+    &[data-size-class="bad"] {
+      background: theme.color("states", "error", "background");
 
-    .fill {
-      background: theme.color("states", "warning", "normal");
+      .fill {
+        background: theme.color("states", "error", "color");
+      }
     }
-  }
 
-  &[data-size-class="warn"] {
-    background: theme.color("states", "yellow", "background");
+    &[data-size-class="weak"] {
+      background: theme.color("states", "warning", "background");
 
-    .fill {
-      background: theme.color("states", "yellow", "normal");
+      .fill {
+        background: theme.color("states", "warning", "normal");
+      }
     }
-  }
 
-  &[data-size-class="ok"] {
-    background: theme.color("states", "lime", "background");
+    &[data-size-class="warn"] {
+      background: theme.color("states", "yellow", "background");
 
-    .fill {
-      background: theme.color("states", "lime", "normal");
+      .fill {
+        background: theme.color("states", "yellow", "normal");
+      }
     }
-  }
 
-  &[data-size-class="good"] {
-    background: theme.color("states", "success", "background");
+    &[data-size-class="ok"] {
+      background: theme.color("states", "lime", "background");
 
-    .fill {
-      background: theme.color("states", "success", "normal");
+      .fill {
+        background: theme.color("states", "lime", "normal");
+      }
     }
+
+    &[data-size-class="good"] {
+      background: theme.color("states", "success", "background");
+
+      .fill {
+        background: theme.color("states", "success", "normal");
+      }
+    }
+  }
+
+  &[data-fill] {
+    width: 100%;
   }
 }

+ 11 - 3
apps/insights/src/components/Score/index.tsx

@@ -10,6 +10,7 @@ const SCORE_WIDTH = 24;
 
 type Props = {
   width?: number | undefined;
+  fill?: boolean | undefined;
 } & (
   | { isLoading: true }
   | {
@@ -18,19 +19,26 @@ type Props = {
     }
 );
 
-export const Score = ({ width, ...props }: Props) =>
+export const Score = ({ width, fill, ...props }: Props) =>
   props.isLoading ? (
     <Skeleton
       className={styles.score}
       fill
-      style={{ "--width": width ?? SCORE_WIDTH } as CSSProperties}
+      data-fill={fill ? "" : undefined}
+      {...(!fill && {
+        style: { "--width": width ?? SCORE_WIDTH } as CSSProperties,
+      })}
     />
   ) : (
     <Meter
+      className={styles.meter ?? ""}
       value={props.score}
       maxValue={1}
-      style={{ "--width": width ?? SCORE_WIDTH } as CSSProperties}
       aria-label="Score"
+      data-fill={fill ? "" : undefined}
+      {...(!fill && {
+        style: { "--width": width ?? SCORE_WIDTH } as CSSProperties,
+      })}
     >
       {({ percentage }) => (
         <div

+ 18 - 18
apps/insights/src/components/Publisher/median-score-history.module.scss → apps/insights/src/components/ScoreHistory/index.module.scss

@@ -1,11 +1,11 @@
 @use "@pythnetwork/component-library/theme";
 
-.medianScoreHistory {
+.scoreHistory {
   display: flex;
   flex-flow: column nowrap;
   gap: theme.spacing(6);
 
-  .medianScoreHistoryChart {
+  .scoreHistoryChart {
     grid-column: span 2 / span 2;
     border-radius: theme.border-radius("2xl");
     border: 1px solid theme.color("border");
@@ -44,64 +44,64 @@
       border-bottom-right-radius: theme.border-radius("2xl");
       overflow: hidden;
 
-      .medianScore,
-      .medianUptimeScore,
-      .medianDeviationScore,
-      .medianStalledScore {
+      .score,
+      .uptimeScore,
+      .deviationScore,
+      .stalledScore {
         transition: opacity 100ms linear;
         opacity: 0.2;
       }
 
-      .medianScore {
+      .score {
         color: theme.color("states", "data", "normal");
       }
 
-      .medianUptimeScore {
+      .uptimeScore {
         color: theme.color("states", "info", "normal");
       }
 
-      .medianDeviationScore {
+      .deviationScore {
         color: theme.color("states", "lime", "normal");
       }
 
-      .medianStalledScore {
+      .stalledScore {
         color: theme.color("states", "warning", "normal");
       }
     }
 
     &:not([data-focused-score], [data-hovered-score]) {
-      .medianScore,
-      .medianUptimeScore,
-      .medianDeviationScore,
-      .medianStalledScore {
+      .score,
+      .uptimeScore,
+      .deviationScore,
+      .stalledScore {
         opacity: 1;
       }
     }
 
     &[data-hovered-score="uptime"],
     &[data-focused-score="uptime"] {
-      .medianUptimeScore {
+      .uptimeScore {
         opacity: 1;
       }
     }
 
     &[data-hovered-score="deviation"],
     &[data-focused-score="deviation"] {
-      .medianDeviationScore {
+      .deviationScore {
         opacity: 1;
       }
     }
 
     &[data-hovered-score="stalled"],
     &[data-focused-score="stalled"] {
-      .medianStalledScore {
+      .stalledScore {
         opacity: 1;
       }
     }
 
     &[data-hovered-score="final"],
     &[data-focused-score="final"] {
-      .medianScore {
+      .score {
         opacity: 1;
       }
     }

+ 67 - 59
apps/insights/src/components/Publisher/median-score-history.tsx → apps/insights/src/components/ScoreHistory/index.tsx

@@ -3,12 +3,18 @@
 import { Card } from "@pythnetwork/component-library/Card";
 import { Table } from "@pythnetwork/component-library/Table";
 import dynamic from "next/dynamic";
-import { Suspense, useState, useCallback, useMemo } from "react";
+import {
+  type ReactNode,
+  Suspense,
+  useState,
+  useCallback,
+  useMemo,
+} from "react";
 import { useDateFormatter, useNumberFormatter } from "react-aria";
 import { ResponsiveContainer, Tooltip, Line, XAxis, YAxis } from "recharts";
 import type { CategoricalChartState } from "recharts/types/chart/types";
 
-import styles from "./median-score-history.module.scss";
+import styles from "./index.module.scss";
 import { Score } from "../Score";
 
 const LineChart = dynamic(
@@ -21,21 +27,22 @@ const LineChart = dynamic(
 const CHART_HEIGHT = 104;
 
 type Props = {
-  medianScoreHistory: Point[];
+  isMedian?: boolean | undefined;
+  scoreHistory: Point[];
 };
 
 type Point = {
   time: Date;
-  medianScore: number;
-  medianUptimeScore: number;
-  medianDeviationScore: number;
-  medianStalledScore: number;
+  score: number;
+  uptimeScore: number;
+  deviationScore: number;
+  stalledScore: number;
 };
 
-export const MedianScoreHistory = ({ medianScoreHistory }: Props) => {
-  const [selectedPoint, setSelectedPoint] = useState<
-    (typeof medianScoreHistory)[number] | undefined
-  >(undefined);
+export const ScoreHistory = ({ isMedian, scoreHistory }: Props) => {
+  const [selectedPoint, setSelectedPoint] = useState<Point | undefined>(
+    undefined,
+  );
   const updateSelectedPoint = useCallback(
     (chart: CategoricalChartState) => {
       setSelectedPoint(
@@ -45,13 +52,15 @@ export const MedianScoreHistory = ({ medianScoreHistory }: Props) => {
     [setSelectedPoint],
   );
   const currentPoint = useMemo(
-    () => selectedPoint ?? medianScoreHistory.at(-1),
-    [selectedPoint, medianScoreHistory],
+    () => selectedPoint ?? scoreHistory.at(-1),
+    [selectedPoint, scoreHistory],
   );
   const dateFormatter = useDateFormatter();
   const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 4 });
 
-  const [hoveredScore, setHoveredScore] = useState<FocusedScore>(undefined);
+  const [hoveredScore, setHoveredScore] = useState<ScoreComponent | undefined>(
+    undefined,
+  );
   const hoverUptime = useCallback(() => {
     setHoveredScore("uptime");
   }, [setHoveredScore]);
@@ -68,7 +77,9 @@ export const MedianScoreHistory = ({ medianScoreHistory }: Props) => {
     setHoveredScore(undefined);
   }, [setHoveredScore]);
 
-  const [focusedScore, setFocusedScore] = useState<FocusedScore>(undefined);
+  const [focusedScore, setFocusedScore] = useState<ScoreComponent | undefined>(
+    undefined,
+  );
   const toggleFocusedScore = useCallback(
     (value: typeof focusedScore) => {
       setFocusedScore((cur) => (cur === value ? undefined : value));
@@ -89,19 +100,20 @@ export const MedianScoreHistory = ({ medianScoreHistory }: Props) => {
   }, [toggleFocusedScore]);
 
   return (
-    <div className={styles.medianScoreHistory}>
+    <div className={styles.scoreHistory}>
       <div
-        className={styles.medianScoreHistoryChart}
+        className={styles.scoreHistoryChart}
         data-hovered-score={hoveredScore}
         data-focused-score={focusedScore}
       >
         <div className={styles.top}>
           <div className={styles.left}>
             <h3 className={styles.header}>
-              <HeaderText
-                hoveredScore={hoveredScore}
-                focusedScore={focusedScore}
-              />
+              <Label
+                isMedian={isMedian}
+                component={hoveredScore ?? focusedScore ?? "final"}
+              />{" "}
+              History
             </h3>
             <div className={styles.subheader}>
               {selectedPoint
@@ -118,43 +130,43 @@ export const MedianScoreHistory = ({ medianScoreHistory }: Props) => {
         >
           <ResponsiveContainer width="100%" height={CHART_HEIGHT}>
             <LineChart
-              data={medianScoreHistory}
+              data={scoreHistory}
               className={styles.chart ?? ""}
               onMouseEnter={updateSelectedPoint}
               onMouseMove={updateSelectedPoint}
               onMouseLeave={updateSelectedPoint}
-              margin={{ bottom: 0, left: 0, top: 0, right: 0 }}
+              margin={{ bottom: 0, left: 0, top: 3, right: 0 }}
             >
               <Tooltip content={() => <></>} />
               <Line
                 type="monotone"
-                dataKey="medianScore"
+                dataKey="score"
                 dot={false}
-                className={styles.medianScore ?? ""}
+                className={styles.score ?? ""}
                 stroke="currentColor"
                 strokeWidth={focusedScore === "final" ? 3 : 1}
               />
               <Line
                 type="monotone"
-                dataKey="medianUptimeScore"
+                dataKey="uptimeScore"
                 dot={false}
-                className={styles.medianUptimeScore ?? ""}
+                className={styles.uptimeScore ?? ""}
                 stroke="currentColor"
                 strokeWidth={focusedScore === "uptime" ? 3 : 1}
               />
               <Line
                 type="monotone"
-                dataKey="medianDeviationScore"
+                dataKey="deviationScore"
                 dot={false}
-                className={styles.medianDeviationScore ?? ""}
+                className={styles.deviationScore ?? ""}
                 stroke="currentColor"
                 strokeWidth={focusedScore === "deviation" ? 3 : 1}
               />
               <Line
                 type="monotone"
-                dataKey="medianStalledScore"
+                dataKey="stalledScore"
                 dot={false}
-                className={styles.medianStalledScore ?? ""}
+                className={styles.stalledScore ?? ""}
                 stroke="currentColor"
                 strokeWidth={focusedScore === "stalled" ? 3 : 1}
               />
@@ -204,13 +216,11 @@ export const MedianScoreHistory = ({ medianScoreHistory }: Props) => {
                 legend: <div className={styles.uptimeLegend} />,
                 metric: (
                   <Metric
-                    name="Median Uptime"
+                    name={<Label isMedian={isMedian} component="uptime" />}
                     description="Percentage of time a publisher is available and active"
                   />
                 ),
-                score: numberFormatter.format(
-                  currentPoint?.medianUptimeScore ?? 0,
-                ),
+                score: numberFormatter.format(currentPoint?.uptimeScore ?? 0),
               },
             },
             {
@@ -222,12 +232,12 @@ export const MedianScoreHistory = ({ medianScoreHistory }: Props) => {
                 legend: <div className={styles.deviationLegend} />,
                 metric: (
                   <Metric
-                    name="Median Price Deviation"
+                    name={<Label isMedian={isMedian} component="deviation" />}
                     description="Deviations that occur between a publishers' price and the aggregate price"
                   />
                 ),
                 score: numberFormatter.format(
-                  currentPoint?.medianDeviationScore ?? 0,
+                  currentPoint?.deviationScore ?? 0,
                 ),
               },
             },
@@ -240,13 +250,11 @@ export const MedianScoreHistory = ({ medianScoreHistory }: Props) => {
                 legend: <div className={styles.stalledLegend} />,
                 metric: (
                   <Metric
-                    name="Median Staleness"
+                    name={<Label isMedian={isMedian} component="stalled" />}
                     description="Penalizes publishers reporting the same value for the price"
                   />
                 ),
-                score: numberFormatter.format(
-                  currentPoint?.medianStalledScore ?? 0,
-                ),
+                score: numberFormatter.format(currentPoint?.stalledScore ?? 0),
               },
             },
             {
@@ -258,11 +266,11 @@ export const MedianScoreHistory = ({ medianScoreHistory }: Props) => {
                 legend: <div className={styles.finalScoreLegend} />,
                 metric: (
                   <Metric
-                    name="Median Final Score"
+                    name={<Label isMedian={isMedian} component="final" />}
                     description="The aggregate score, calculated by combining the other three score components"
                   />
                 ),
-                score: numberFormatter.format(currentPoint?.medianScore ?? 0),
+                score: numberFormatter.format(currentPoint?.score ?? 0),
               },
             },
           ]}
@@ -273,54 +281,54 @@ export const MedianScoreHistory = ({ medianScoreHistory }: Props) => {
 };
 
 type HeaderTextProps = {
-  focusedScore: FocusedScore;
-  hoveredScore: FocusedScore;
+  isMedian?: boolean | undefined;
+  component: ScoreComponent;
 };
 
-const HeaderText = ({ hoveredScore, focusedScore }: HeaderTextProps) => {
-  switch (focusedScore ?? hoveredScore) {
+const Label = ({ isMedian, component }: HeaderTextProps) => {
+  switch (component) {
     case "uptime": {
-      return "Median Uptime Score History";
+      return `${isMedian ? "Median " : ""}Uptime Score`;
     }
     case "deviation": {
-      return "Median Deviation Score History";
+      return `${isMedian ? "Median " : ""}Deviation Score`;
     }
     case "stalled": {
-      return "Median Stalled Score History";
+      return `${isMedian ? "Median " : ""}Stalled Score`;
     }
-    default: {
-      return "Median Score History";
+    case "final": {
+      return `${isMedian ? "Median " : ""}Final Score`;
     }
   }
 };
 
-type FocusedScore = "uptime" | "deviation" | "stalled" | "final" | undefined;
+type ScoreComponent = "uptime" | "deviation" | "stalled" | "final";
 
 type CurrentValueProps = {
   point: Point;
-  focusedScore: FocusedScore;
+  focusedScore: ScoreComponent | undefined;
 };
 
 const CurrentValue = ({ point, focusedScore }: CurrentValueProps) => {
   const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 4 });
   switch (focusedScore) {
     case "uptime": {
-      return numberFormatter.format(point.medianUptimeScore);
+      return numberFormatter.format(point.uptimeScore);
     }
     case "deviation": {
-      return numberFormatter.format(point.medianDeviationScore);
+      return numberFormatter.format(point.deviationScore);
     }
     case "stalled": {
-      return numberFormatter.format(point.medianStalledScore);
+      return numberFormatter.format(point.stalledScore);
     }
     default: {
-      return <Score score={point.medianScore} />;
+      return <Score score={point.score} />;
     }
   }
 };
 
 type MetricProps = {
-  name: string;
+  name: ReactNode;
   description: string;
 };
 

+ 37 - 0
apps/insights/src/components/Status/index.tsx

@@ -0,0 +1,37 @@
+import { Status as StatusComponent } from "@pythnetwork/component-library/Status";
+
+import { Status as StatusType } from "../../status";
+
+export const Status = ({ status }: { status: StatusType }) => (
+  <StatusComponent variant={getVariant(status)}>
+    {getText(status)}
+  </StatusComponent>
+);
+
+const getVariant = (status: StatusType) => {
+  switch (status) {
+    case StatusType.Active: {
+      return "success";
+    }
+    case StatusType.Inactive: {
+      return "disabled";
+    }
+    case StatusType.Unranked: {
+      return "error";
+    }
+  }
+};
+
+const getText = (status: StatusType) => {
+  switch (status) {
+    case StatusType.Active: {
+      return "Active";
+    }
+    case StatusType.Inactive: {
+      return "Inactive";
+    }
+    case StatusType.Unranked: {
+      return "Unranked";
+    }
+  }
+};

+ 3 - 0
apps/insights/src/config/server.ts

@@ -53,3 +53,6 @@ export const CLICKHOUSE = {
 
 export const SOLANA_RPC =
   process.env.SOLANA_RPC ?? "https://api.mainnet-beta.solana.com";
+
+export const ENABLE_ACCESSIBILITY_REPORTING =
+  !IS_PRODUCTION_SERVER && !process.env.DISABLE_ACCESSIBILITY_REPORTING;

+ 156 - 101
apps/insights/src/services/clickhouse.ts

@@ -3,6 +3,7 @@ import "server-only";
 import { createClient } from "@clickhouse/client";
 import { z, type ZodSchema, type ZodTypeDef } from "zod";
 
+import { Cluster, ClusterToName } from "./pyth";
 import { cache } from "../cache";
 import { CLICKHOUSE } from "../config/server";
 
@@ -23,8 +24,10 @@ export const getPublishers = cache(
         }),
       ),
       {
-        query:
-          "SELECT key, rank, numSymbols, medianScore FROM insights_publishers(cluster={cluster: String})",
+        query: `
+          SELECT key, rank, numSymbols, medianScore
+          FROM insights_publishers(cluster={cluster: String})
+        `,
         query_params: { cluster: "pythnet" },
       },
     ),
@@ -34,83 +37,54 @@ export const getPublishers = cache(
   },
 );
 
-export const getRankings = cache(
-  async (symbol: string) =>
-    safeQuery(
-      z.array(
-        rankingSchema.extend({
-          cluster: z.enum(["pythnet", "pythtest-conformance"]),
-          publisher: z.string(),
-        }),
-      ),
-      {
-        query: `
-      SELECT
-        cluster,
-        publisher,
-        uptime_score,
-        uptime_rank,
-        deviation_penalty,
-        deviation_score,
-        deviation_rank,
-        stalled_penalty,
-        stalled_score,
-        stalled_rank,
-        final_score
-      FROM insights_feed_component_rankings(symbol={symbol: String})
-    `,
-        query_params: { symbol },
-      },
-    ),
-  ["rankings"],
+export const getRankingsByPublisher = cache(
+  async (publisherKey: string) =>
+    safeQuery(rankingsSchema, {
+      query: `
+          SELECT * FROM insights__rankings
+          WHERE publisher = {publisherKey: String}
+        `,
+      query_params: { publisherKey },
+    }),
+  ["rankingsByPublisher"],
   {
     revalidate: ONE_HOUR_IN_SECONDS,
   },
 );
 
-export const getPublisherFeeds = cache(
-  async (publisherKey: string) =>
-    safeQuery(
-      z.array(
-        rankingSchema.extend({
-          symbol: z.string(),
-        }),
-      ),
-      {
-        query: `
-      SELECT
-        symbol,
-        uptime_score,
-        uptime_rank,
-        deviation_penalty,
-        deviation_score,
-        deviation_rank,
-        stalled_penalty,
-        stalled_score,
-        stalled_rank,
-        final_score
-      FROM insights_feeds_for_publisher(publisherKey={publisherKey: String})
-    `,
-        query_params: { publisherKey },
-      },
-    ),
-  ["publisher-feeds"],
+export const getRankingsBySymbol = cache(
+  async (symbol: string) =>
+    safeQuery(rankingsSchema, {
+      query: `
+          SELECT * FROM insights__rankings
+          WHERE symbol = {symbol: String}
+        `,
+      query_params: { symbol },
+    }),
+  ["rankingsBySymbol"],
   {
     revalidate: ONE_HOUR_IN_SECONDS,
   },
 );
 
-const rankingSchema = z.strictObject({
-  uptime_score: z.number(),
-  uptime_rank: z.number(),
-  deviation_penalty: z.number().nullable(),
-  deviation_score: z.number(),
-  deviation_rank: z.number(),
-  stalled_penalty: z.number(),
-  stalled_score: z.number(),
-  stalled_rank: z.number(),
-  final_score: z.number(),
-});
+const rankingsSchema = z.array(
+  z.strictObject({
+    symbol: z.string(),
+    cluster: z.enum(["pythnet", "pythtest-conformance"]),
+    publisher: z.string(),
+    uptime_score: z.number(),
+    uptime_rank: z.number(),
+    deviation_penalty: z.number().nullable(),
+    deviation_score: z.number(),
+    deviation_rank: z.number(),
+    stalled_penalty: z.number(),
+    stalled_score: z.number(),
+    stalled_rank: z.number(),
+    final_score: z.number(),
+    final_rank: z.number(),
+    is_active: z.number().transform((value) => value === 1),
+  }),
+);
 
 export const getYesterdaysPrices = cache(
   async (symbols: string[]) =>
@@ -122,8 +96,10 @@ export const getYesterdaysPrices = cache(
         }),
       ),
       {
-        query:
-          "select symbol, price from insights_yesterdays_prices(symbols={symbols: Array(String)})",
+        query: `
+          SELECT symbol, price
+          FROM insights_yesterdays_prices(symbols={symbols: Array(String)})
+        `,
         query_params: { symbols },
       },
     ),
@@ -144,16 +120,16 @@ export const getPublisherRankingHistory = cache(
       ),
       {
         query: `
-        SELECT * FROM (
-          SELECT timestamp, rank
-          FROM publishers_ranking
-          WHERE publisher = {key: String}
-          AND cluster = 'pythnet'
-          ORDER BY timestamp DESC
-          LIMIT 30
-        )
-        ORDER BY timestamp ASC
-      `,
+          SELECT * FROM (
+            SELECT timestamp, rank
+            FROM publishers_ranking
+            WHERE publisher = {key: String}
+            AND cluster = 'pythnet'
+            ORDER BY timestamp DESC
+            LIMIT 30
+          )
+          ORDER BY timestamp ASC
+        `,
         query_params: { key },
       },
     ),
@@ -161,36 +137,115 @@ export const getPublisherRankingHistory = cache(
   { revalidate: ONE_HOUR_IN_SECONDS },
 );
 
+export const getFeedScoreHistory = cache(
+  async (cluster: Cluster, publisherKey: string, symbol: string) =>
+    safeQuery(
+      z.array(
+        z.strictObject({
+          time: z.string().transform((value) => new Date(value)),
+          score: z.number(),
+          uptimeScore: z.number(),
+          deviationScore: z.number(),
+          stalledScore: z.number(),
+        }),
+      ),
+      {
+        query: `
+          SELECT * FROM (
+            SELECT
+              time,
+              final_score AS score,
+              uptime_score AS uptimeScore,
+              deviation_score AS deviationScore,
+              stalled_score AS stalledScore
+            FROM default.publisher_quality_ranking
+            WHERE publisher = {publisherKey: String}
+            AND cluster = {cluster: String}
+            AND symbol = {symbol: String}
+            ORDER BY time DESC
+            LIMIT 30
+          )
+          ORDER BY time ASC
+        `,
+        query_params: {
+          cluster: ClusterToName[cluster],
+          publisherKey,
+          symbol,
+        },
+      },
+    ),
+  ["feed-score-history"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);
+
+export const getFeedPriceHistory = cache(
+  async (cluster: Cluster, publisherKey: string, symbol: string) =>
+    safeQuery(
+      z.array(
+        z.strictObject({
+          time: z.string().transform((value) => new Date(value)),
+          price: z.number(),
+          confidence: z.number(),
+        }),
+      ),
+      {
+        query: `
+          SELECT * FROM (
+            SELECT time, price, confidence
+            FROM prices
+            WHERE publisher = {publisherKey: String}
+            AND cluster = {cluster: String}
+            AND symbol = {symbol: String}
+            ORDER BY time DESC
+            LIMIT 30
+          )
+          ORDER BY time ASC
+        `,
+        query_params: {
+          cluster: ClusterToName[cluster],
+          publisherKey,
+          symbol,
+        },
+      },
+    ),
+  ["feed-price-history"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);
+
 export const getPublisherMedianScoreHistory = cache(
   async (key: string) =>
     safeQuery(
       z.array(
         z.strictObject({
           time: z.string().transform((value) => new Date(value)),
-          medianScore: z.number(),
-          medianUptimeScore: z.number(),
-          medianDeviationScore: z.number(),
-          medianStalledScore: z.number(),
+          score: z.number(),
+          uptimeScore: z.number(),
+          deviationScore: z.number(),
+          stalledScore: z.number(),
         }),
       ),
       {
         query: `
-        SELECT * FROM (
-          SELECT
-            time,
-            medianExact(final_score) AS medianScore,
-            medianExact(uptime_score) AS medianUptimeScore,
-            medianExact(deviation_score) AS medianDeviationScore,
-            medianExact(stalled_score) AS medianStalledScore
-          FROM default.publisher_quality_ranking
-          WHERE publisher = {key: String}
-          AND cluster = 'pythnet'
-          GROUP BY time
-          ORDER BY time DESC
-          LIMIT 30
-        )
-        ORDER BY time ASC
-      `,
+          SELECT * FROM (
+            SELECT
+              time,
+              medianExact(final_score) AS score,
+              medianExact(uptime_score) AS uptimeScore,
+              medianExact(deviation_score) AS deviationScore,
+              medianExact(stalled_score) AS stalledScore
+            FROM default.publisher_quality_ranking
+            WHERE publisher = {key: String}
+            AND cluster = 'pythnet'
+            GROUP BY time
+            ORDER BY time DESC
+            LIMIT 30
+          )
+          ORDER BY time ASC
+        `,
         query_params: { key },
       },
     ),

+ 72 - 16
apps/insights/src/services/pyth.ts

@@ -12,21 +12,63 @@ import { cache } from "../cache";
 
 const ONE_MINUTE_IN_SECONDS = 60;
 const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS;
-const CLUSTER = "pythnet";
 
-const connection = new Connection(getPythClusterApiUrl(CLUSTER));
-const programKey = getPythProgramKeyForCluster(CLUSTER);
-export const client = new PythHttpClient(connection, programKey);
+export enum Cluster {
+  Pythnet,
+  PythtestConformance,
+}
+
+export const ClusterToName = {
+  [Cluster.Pythnet]: "pythnet",
+  [Cluster.PythtestConformance]: "pythtest-conformance",
+} as const;
+
+export const CLUSTER_NAMES = ["pythnet", "pythtest-conformance"] as const;
+
+export const toCluster = (name: (typeof CLUSTER_NAMES)[number]): Cluster => {
+  switch (name) {
+    case "pythnet": {
+      return Cluster.Pythnet;
+    }
+    case "pythtest-conformance": {
+      return Cluster.PythtestConformance;
+    }
+  }
+};
+
+const mkConnection = (cluster: Cluster) =>
+  new Connection(getPythClusterApiUrl(ClusterToName[cluster]));
+
+const connections = {
+  [Cluster.Pythnet]: mkConnection(Cluster.Pythnet),
+  [Cluster.PythtestConformance]: mkConnection(Cluster.PythtestConformance),
+} as const;
+
+const mkClient = (cluster: Cluster) =>
+  new PythHttpClient(
+    connections[cluster],
+    getPythProgramKeyForCluster(ClusterToName[cluster]),
+  );
+
+const clients = {
+  [Cluster.Pythnet]: mkClient(Cluster.Pythnet),
+  [Cluster.PythtestConformance]: mkClient(Cluster.PythtestConformance),
+} as const;
 
 export const getData = cache(
-  async () => {
-    const data = await client.getData();
+  async (cluster: Cluster) => {
+    const data = await clients[cluster].getData();
     return priceFeedsSchema.parse(
-      data.symbols.map((symbol) => ({
-        symbol,
-        product: data.productFromSymbol.get(symbol),
-        price: data.productPrice.get(symbol),
-      })),
+      data.symbols
+        .filter(
+          (symbol) =>
+            data.productFromSymbol.get(symbol)?.display_symbol !== undefined,
+        )
+        .map((symbol) => ({
+          symbol,
+          product: data.productFromSymbol.get(symbol),
+          price: data.productPrice.get(symbol),
+        })),
     );
   },
   ["pyth-data"],
@@ -62,19 +104,33 @@ const priceFeedsSchema = z.array(
       minPublishers: z.number(),
       lastSlot: z.bigint(),
       validSlot: z.bigint(),
+      priceComponents: z.array(
+        z.object({
+          publisher: z.instanceof(PublicKey).transform((key) => key.toBase58()),
+        }),
+      ),
     }),
   }),
 );
 
-export const getTotalFeedCount = async () => {
-  const pythData = await getData();
+export const getTotalFeedCount = async (cluster: Cluster) => {
+  const pythData = await getData(cluster);
   return pythData.filter(({ price }) => price.numComponentPrices > 0).length;
 };
 
-export const subscribe = (feeds: PublicKey[], cb: PythPriceCallback) => {
+export const getAssetPricesFromAccounts = (
+  cluster: Cluster,
+  ...args: Parameters<(typeof clients)[Cluster]["getAssetPricesFromAccounts"]>
+) => clients[cluster].getAssetPricesFromAccounts(...args);
+
+export const subscribe = (
+  cluster: Cluster,
+  feeds: PublicKey[],
+  cb: PythPriceCallback,
+) => {
   const pythConn = new PythConnection(
-    connection,
-    programKey,
+    connections[cluster],
+    getPythProgramKeyForCluster(ClusterToName[cluster]),
     "confirmed",
     feeds,
   );

+ 13 - 0
apps/insights/src/status.ts

@@ -0,0 +1,13 @@
+export enum Status {
+  Unranked,
+  Inactive,
+  Active,
+}
+
+export const getStatus = (ranking?: { is_active: boolean }): Status => {
+  if (ranking) {
+    return ranking.is_active ? Status.Active : Status.Inactive;
+  } else {
+    return Status.Unranked;
+  }
+};

+ 11 - 2
apps/insights/src/use-data.ts

@@ -2,8 +2,17 @@ import { useLogger } from "@pythnetwork/app-logger";
 import { useCallback } from "react";
 import useSWR, { type KeyedMutator } from "swr";
 
-export const useData = <T>(...args: Parameters<typeof useSWR<T>>) => {
-  const { data, isLoading, mutate, ...rest } = useSWR(...args);
+export const useData = <T>(
+  key: Parameters<typeof useSWR<T>>[0],
+  fetcher?: Parameters<typeof useSWR<T>>[1],
+  config?: Parameters<typeof useSWR<T>>[2],
+) => {
+  const { data, isLoading, mutate, ...rest } = useSWR(
+    key,
+    // eslint-disable-next-line unicorn/no-null
+    fetcher ?? null,
+    config,
+  );
 
   const error = rest.error as unknown;
   const logger = useLogger();

+ 2 - 1
apps/insights/turbo.json

@@ -12,7 +12,8 @@
         "CLICKHOUSE_URL",
         "CLICKHOUSE_USERNAME",
         "CLICKHOUSE_PASSWORD",
-        "SOLANA_RPC"
+        "SOLANA_RPC",
+        "DISABLE_ACCESSIBILITY_REPORTING"
       ]
     },
     "fix:lint": {

+ 20 - 8
packages/component-library/src/Badge/index.module.scss

@@ -6,8 +6,9 @@
   transition-property: color, background-color, border-color;
   transition-duration: 100ms;
   transition-timing-function: linear;
-  border: 1px solid var(--badge-color);
   white-space: nowrap;
+  border-width: 1px;
+  border-style: solid;
 
   &[data-size="xs"] {
     line-height: theme.spacing(4);
@@ -35,20 +36,31 @@
 
   @each $variant in ("neutral", "info", "warning", "error", "data", "success") {
     &[data-variant="#{$variant}"] {
-      --badge-color: #{theme.color("states", $variant, "normal")};
+      border-color: theme.color("states", $variant, "normal");
+
+      &[data-style="filled"] {
+        background: theme.color("states", $variant, "normal");
+      }
+
+      &[data-style="outline"] {
+        color: theme.color("states", $variant, "normal");
+      }
     }
   }
 
   &[data-variant="muted"] {
-    --badge-color: #{theme.color("muted")};
+    border-color: theme.color("muted");
+
+    &[data-style="filled"] {
+      background: theme.color("muted");
+    }
+
+    &[data-style="outline"] {
+      color: theme.color("muted");
+    }
   }
 
   &[data-style="filled"] {
     color: theme.color("background", "primary");
-    background: var(--badge-color);
-  }
-
-  &[data-style="outline"] {
-    color: var(--badge-color);
   }
 }

+ 7 - 0
packages/component-library/src/Drawer/index.module.scss

@@ -40,6 +40,13 @@
         flex-flow: row nowrap;
         gap: theme.spacing(3);
       }
+
+      .headingEnd {
+        display: flex;
+        flex-flow: row nowrap;
+        gap: theme.spacing(3);
+        align-items: center;
+      }
     }
 
     .body {

+ 17 - 12
packages/component-library/src/Drawer/index.tsx

@@ -19,6 +19,7 @@ type OwnProps = {
   title: ReactNode;
   closeHref?: string | undefined;
   footer?: ReactNode | undefined;
+  headingExtra?: ReactNode | undefined;
   headingClassName?: string | undefined;
   bodyClassName?: string | undefined;
   footerClassName?: string | undefined;
@@ -40,6 +41,7 @@ export const Drawer = ({
   headingClassName,
   bodyClassName,
   footerClassName,
+  headingExtra,
   ...props
 }: Props) => (
   <ModalDialog
@@ -73,18 +75,21 @@ export const Drawer = ({
           <Heading className={styles.title} slot="title">
             {title}
           </Heading>
-          <Button
-            className={styles.closeButton ?? ""}
-            beforeIcon={(props) => <XCircle weight="fill" {...props} />}
-            slot="close"
-            hideText
-            rounded
-            variant="ghost"
-            size="sm"
-            {...(closeHref && { href: closeHref })}
-          >
-            Close
-          </Button>
+          <div className={styles.headingEnd}>
+            {headingExtra}
+            <Button
+              className={styles.closeButton ?? ""}
+              beforeIcon={(props) => <XCircle weight="fill" {...props} />}
+              slot="close"
+              hideText
+              rounded
+              variant="ghost"
+              size="sm"
+              {...(closeHref && { href: closeHref })}
+            >
+              Close
+            </Button>
+          </div>
         </div>
         <div className={clsx(styles.body, bodyClassName)}>
           {typeof children === "function" ? children(...args) : children}

+ 9 - 0
packages/component-library/src/ModalDialog/index.tsx

@@ -36,6 +36,12 @@ export const ModalDialogTrigger = (
     [setAnimation],
   );
 
+  useEffect(() => {
+    if (props.defaultOpen) {
+      setAnimation("visible");
+    }
+  }, [props.defaultOpen]);
+
   return (
     <ModalAnimationContext value={[animation, setAnimation]}>
       <DialogTrigger onOpenChange={handleOpenChange} {...props} />
@@ -72,6 +78,7 @@ type OwnProps = Pick<ComponentProps<typeof Modal>, "children"> &
     overlayVariants?:
       | ComponentProps<typeof MotionModalOverlay>["variants"]
       | undefined;
+    onCloseFinish?: (() => void) | undefined;
   };
 
 type Props = Omit<ComponentProps<typeof MotionDialog>, keyof OwnProps> &
@@ -80,6 +87,7 @@ type Props = Omit<ComponentProps<typeof MotionDialog>, keyof OwnProps> &
 export const ModalDialog = ({
   isOpen,
   onOpenChange,
+  onCloseFinish,
   overlayClassName,
   overlayVariants,
   children,
@@ -100,6 +108,7 @@ export const ModalDialog = ({
   const endAnimation = (animation: AnimationState) => {
     if (animation === "hidden") {
       hideOverlay();
+      onCloseFinish?.();
     }
     setAnimation((a) => {
       return animation === "hidden" && a === "hidden" ? "unmounted" : a;

+ 31 - 0
packages/component-library/src/Spinner/index.module.scss

@@ -0,0 +1,31 @@
+@use "../theme";
+
+.spinnerContainer {
+  overflow: hidden;
+
+  .spinner {
+    transform: rotate(-90deg);
+
+    @keyframes spin {
+      from {
+        transform: rotate(-25deg);
+      }
+
+      to {
+        transform: rotate(335deg);
+      }
+    }
+
+    .background {
+      stroke: theme.color("border");
+    }
+
+    .indicator {
+      stroke: theme.color("foreground");
+    }
+  }
+
+  &:not([aria-valuenow]) .spinner {
+    animation: spin 1s ease-in-out infinite;
+  }
+}

+ 23 - 0
packages/component-library/src/Spinner/index.stories.tsx

@@ -0,0 +1,23 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Spinner as SpinnerComponent } from "./index.js";
+
+const meta = {
+  component: SpinnerComponent,
+  argTypes: {
+    label: {
+      control: "text",
+      table: {
+        category: "Spinner",
+      },
+    },
+  },
+} satisfies Meta<typeof SpinnerComponent>;
+export default meta;
+
+export const Spinner = {
+  args: {
+    label: "Spinner",
+    isIndeterminate: true,
+  },
+} satisfies StoryObj<typeof SpinnerComponent>;

+ 45 - 0
packages/component-library/src/Spinner/index.tsx

@@ -0,0 +1,45 @@
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+import { ProgressBar } from "react-aria-components";
+
+import styles from "./index.module.scss";
+
+type OwnProps = {
+  label: string;
+};
+type Props = Omit<ComponentProps<typeof ProgressBar>, keyof OwnProps> &
+  OwnProps;
+
+export const Spinner = ({ label, className, ...props }: Props) => (
+  <ProgressBar
+    aria-label={label}
+    className={clsx(styles.spinnerContainer, className)}
+    {...props}
+  >
+    {({ percentage }) => (
+      <>
+        <svg
+          width="1em"
+          height="1em"
+          viewBox="0 0 32 32"
+          fill="none"
+          className={styles.spinner}
+          strokeWidth={4}
+        >
+          <circle cx={16} cy={16} r={12} className={styles.background} />
+          <circle
+            cx={16}
+            cy={16}
+            r={12}
+            className={styles.indicator}
+            strokeDasharray={`${c.toString()} ${c.toString()}`}
+            strokeDashoffset={c - ((percentage ?? 10) / 100) * c}
+            strokeLinecap="round"
+          />
+        </svg>
+      </>
+    )}
+  </ProgressBar>
+);
+
+const c = 24 * Math.PI;

+ 20 - 2
packages/component-library/src/StatCard/index.module.scss

@@ -5,13 +5,18 @@
   .top {
     display: flex;
     flex-flow: column nowrap;
-    gap: theme.spacing(4);
+    justify-content: space-between;
+  }
+
+  .top {
+    height: theme.spacing(15);
   }
 
   .cardContents {
     padding: theme.spacing(3);
     padding-bottom: theme.spacing(2);
     justify-content: space-between;
+    gap: theme.spacing(4);
     height: 100%;
     width: 100%;
 
@@ -54,14 +59,27 @@
       }
 
       .mainStat {
-        @include theme.h3;
+        @include theme.text("2xl", "medium");
 
+        letter-spacing: theme.letter-spacing("tighter");
+        line-height: theme.spacing(8);
+        height: theme.spacing(8);
+        display: flex;
+        align-items: center;
         color: theme.color("heading");
+        flex-grow: 1;
+        text-align: left;
       }
 
       .miniStat {
         @include theme.text("sm", "medium");
       }
+
+      &[data-small] {
+        .mainStat {
+          font-size: theme.font-size("lg");
+        }
+      }
     }
   }
 

+ 6 - 1
packages/component-library/src/StatCard/index.tsx

@@ -9,6 +9,7 @@ type OwnPropsSingle = {
   stat: ReactNode;
   miniStat?: ReactNode | undefined;
   corner?: ReactNode | undefined;
+  small?: boolean | undefined;
 };
 
 type OwnPropsDual = {
@@ -42,6 +43,7 @@ export const StatCard = <T extends ElementType>({
     stat,
     miniStat,
     corner,
+    small,
     header1,
     header2,
     stat1,
@@ -61,7 +63,10 @@ export const StatCard = <T extends ElementType>({
                 <div className={styles.corner}>{props.corner}</div>
               )}
               <h2 className={styles.header}>{props.header}</h2>
-              <div className={styles.stats}>
+              <div
+                data-small={props.small ? "" : undefined}
+                className={styles.stats}
+              >
                 <div className={styles.mainStat}>{props.stat}</div>
                 {props.miniStat && (
                   <div className={styles.miniStat}>{props.miniStat}</div>

+ 91 - 0
packages/component-library/src/Status/index.module.scss

@@ -0,0 +1,91 @@
+@use "../theme";
+
+.status {
+  display: inline flow-root;
+  border-radius: theme.border-radius("3xl");
+  transition-property: color, background-color, border-color;
+  transition-duration: 100ms;
+  transition-timing-function: linear;
+  white-space: nowrap;
+  border-width: 1px;
+  border-style: solid;
+
+  .dot {
+    display: inline-block;
+    border-radius: theme.border-radius("full");
+    position: relative;
+    transition: background-color 100ms linear;
+  }
+
+  &[data-size="xs"] {
+    height: theme.spacing(4);
+    padding: 0 theme.spacing(1);
+    font-size: theme.font-size("xxs");
+    font-weight: theme.font-weight("medium");
+
+    .text {
+      line-height: theme.spacing(4);
+      padding: 0 theme.spacing(1);
+    }
+
+    .dot {
+      width: theme.spacing(2);
+      height: theme.spacing(2);
+      top: theme.spacing(0.25);
+    }
+  }
+
+  &[data-size="md"] {
+    height: theme.spacing(6);
+    padding: 0 theme.spacing(1.5);
+    font-size: theme.font-size("xs");
+    font-weight: theme.font-weight("medium");
+
+    .text {
+      line-height: theme.spacing(6);
+      padding: 0 theme.spacing(1.5);
+    }
+
+    .dot {
+      width: theme.spacing(3);
+      height: theme.spacing(3);
+      top: theme.spacing(0.5);
+    }
+  }
+
+  @each $variant in ("neutral", "info", "warning", "error", "data", "success") {
+    &[data-variant="#{$variant}"] {
+      color: theme.color("states", $variant, "normal");
+
+      .dot {
+        background-color: theme.color("states", $variant, "normal");
+      }
+
+      &[data-style="outline"] {
+        border-color: theme.color("states", $variant, "border");
+      }
+
+      &[data-style="filled"] {
+        border-color: theme.color("states", $variant, "background");
+        background-color: theme.color("states", $variant, "background");
+      }
+    }
+  }
+
+  &[data-variant="disabled"] {
+    color: theme.color("button", "disabled", "foreground");
+
+    .dot {
+      background-color: theme.color("button", "disabled", "foreground");
+    }
+
+    &[data-style="outline"] {
+      border-color: theme.color("button", "disabled", "foreground");
+    }
+
+    &[data-style="filled"] {
+      border-color: theme.color("button", "disabled", "background");
+      background-color: theme.color("button", "disabled", "background");
+    }
+  }
+}

+ 46 - 0
packages/component-library/src/Status/index.stories.tsx

@@ -0,0 +1,46 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Status as StatusComponent, VARIANTS, SIZES, STYLES } from "./index.js";
+
+const meta = {
+  component: StatusComponent,
+  argTypes: {
+    children: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+    variant: {
+      control: "inline-radio",
+      options: VARIANTS,
+      table: {
+        category: "Variant",
+      },
+    },
+    style: {
+      control: "inline-radio",
+      options: STYLES,
+      table: {
+        category: "Variant",
+      },
+    },
+    size: {
+      control: "inline-radio",
+      options: SIZES,
+      table: {
+        category: "Variant",
+      },
+    },
+  },
+} satisfies Meta<typeof StatusComponent>;
+export default meta;
+
+export const Status = {
+  args: {
+    children: "A STATUS",
+    variant: "neutral",
+    style: "filled",
+    size: "md",
+  },
+} satisfies StoryObj<typeof StatusComponent>;

+ 42 - 0
packages/component-library/src/Status/index.tsx

@@ -0,0 +1,42 @@
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+
+import styles from "./index.module.scss";
+
+export const VARIANTS = [
+  "neutral",
+  "info",
+  "warning",
+  "error",
+  "data",
+  "success",
+  "disabled",
+] as const;
+export const STYLES = ["filled", "outline"] as const;
+export const SIZES = ["xs", "md"] as const;
+
+type Props = ComponentProps<"span"> & {
+  variant?: (typeof VARIANTS)[number] | undefined;
+  style?: (typeof STYLES)[number] | undefined;
+  size?: (typeof SIZES)[number] | undefined;
+};
+
+export const Status = ({
+  className,
+  variant = "neutral",
+  size = "md",
+  style = "filled",
+  children,
+  ...props
+}: Props) => (
+  <span
+    className={clsx(styles.status, className)}
+    data-variant={variant}
+    data-size={size}
+    data-style={style}
+    {...props}
+  >
+    <span className={styles.dot} />
+    <span className={styles.text}>{children}</span>
+  </span>
+);

+ 121 - 94
packages/component-library/src/Table/index.tsx

@@ -32,11 +32,21 @@ type TableProps<T extends string> = ComponentProps<typeof UnstyledTable> & {
   columns: ColumnConfig<T>[];
   isLoading?: boolean | undefined;
   isUpdating?: boolean | undefined;
-  renderEmptyState?: TableBodyProps<T>["renderEmptyState"] | undefined;
   dependencies?: TableBodyProps<T>["dependencies"] | undefined;
 } & (
     | { isLoading: true; rows?: RowConfig<T>[] | undefined }
     | { isLoading?: false | undefined; rows: RowConfig<T>[] }
+  ) &
+  (
+    | { hideHeadersInEmptyState?: undefined }
+    | ({ hideHeadersInEmptyState?: boolean } & (
+        | { emptyState: ReactNode }
+        | {
+            renderEmptyState: NonNullable<
+              TableBodyProps<T>["renderEmptyState"]
+            >;
+          }
+      ))
   );
 
 export type ColumnConfig<T extends string> = Omit<ColumnProps, "children"> & {
@@ -70,7 +80,6 @@ export const Table = <T extends string>({
   columns,
   isLoading,
   isUpdating,
-  renderEmptyState,
   dependencies,
   headerCellClassName,
   stickyHeader,
@@ -86,105 +95,123 @@ export const Table = <T extends string>({
         <div className={styles.loader} />
       </div>
     )}
-    <UnstyledTable aria-label={label} className={styles.table ?? ""} {...props}>
-      <TableHeader columns={columns} className={styles.tableHeader ?? ""}>
-        {(column: ColumnConfig<T>) => (
-          <Column
-            data-sticky-header={stickyHeader === undefined ? undefined : ""}
-            {...column}
-            {...cellProps(column, headerCellClassName, {
-              "--sticky-header-top":
-                typeof stickyHeader === "string" ? stickyHeader : 0,
-            } as CSSProperties)}
-          >
-            {({ allowsSorting, sort, sortDirection }) => (
-              <>
-                {column.name}
-                {allowsSorting && (
-                  <Button
-                    className={styles.sortButton ?? ""}
-                    size="xs"
-                    variant="ghost"
-                    onPress={() => {
-                      sort(
-                        sortDirection === "ascending"
-                          ? "descending"
-                          : "ascending",
-                      );
-                    }}
-                    beforeIcon={(props) => (
-                      <svg
-                        xmlns="http://www.w3.org/2000/svg"
-                        viewBox="0 0 16 16"
-                        fill="currentColor"
-                        {...props}
-                      >
-                        <path
-                          className={styles.ascending}
-                          d="m10.677 6.073-2.5-2.5a.25.25 0 0 0-.354 0l-2.5 2.5A.25.25 0 0 0 5.5 6.5h5a.25.25 0 0 0 .177-.427Z"
-                        />
-                        <path
-                          className={styles.descending}
-                          d="m10.677 9.927-2.5 2.5a.25.25 0 0 1-.354 0l-2.5-2.5A.25.25 0 0 1 5.5 9.5h5a.25.25 0 0 1 .177.427Z"
-                        />
-                      </svg>
-                    )}
-                    hideText
-                  >
-                    Sort
-                  </Button>
-                )}
-                <div className={styles.divider} />
-              </>
-            )}
-          </Column>
-        )}
-      </TableHeader>
-      <TableBody
-        items={isLoading ? [] : rows}
-        className={styles.tableBody ?? ""}
-        {...(dependencies !== undefined && { dependencies })}
-        {...(renderEmptyState !== undefined && { renderEmptyState })}
+    {props.hideHeadersInEmptyState === true && rows?.length === 0 ? (
+      <>
+        {"renderEmptyState" in props
+          ? props.renderEmptyState({ isEmpty: true, isDropTarget: false })
+          : props.emptyState}
+      </>
+    ) : (
+      <UnstyledTable
+        aria-label={label}
+        className={styles.table ?? ""}
+        {...props}
       >
-        {isLoading ? (
-          <Row
-            id="loading"
-            key="loading"
-            className={styles.row ?? ""}
-            columns={columns}
-          >
-            {(column: ColumnConfig<T>) => (
-              <Cell {...cellProps(column)}>
-                {"loadingSkeleton" in column ? (
-                  column.loadingSkeleton
-                ) : (
-                  <Skeleton
-                    width={
-                      "loadingSkeletonWidth" in column
-                        ? column.loadingSkeletonWidth
-                        : column.width
-                    }
-                  />
-                )}
-              </Cell>
-            )}
-          </Row>
-        ) : (
-          ({ className: rowClassName, data, ...row }: RowConfig<T>) => (
+        <TableHeader columns={columns} className={styles.tableHeader ?? ""}>
+          {(column: ColumnConfig<T>) => (
+            <Column
+              data-sticky-header={stickyHeader === undefined ? undefined : ""}
+              {...column}
+              {...cellProps(column, headerCellClassName, {
+                "--sticky-header-top":
+                  typeof stickyHeader === "string" ? stickyHeader : 0,
+              } as CSSProperties)}
+            >
+              {({ allowsSorting, sort, sortDirection }) => (
+                <>
+                  {column.name}
+                  {allowsSorting && (
+                    <Button
+                      className={styles.sortButton ?? ""}
+                      size="xs"
+                      variant="ghost"
+                      onPress={() => {
+                        sort(
+                          sortDirection === "ascending"
+                            ? "descending"
+                            : "ascending",
+                        );
+                      }}
+                      beforeIcon={(props) => (
+                        <svg
+                          xmlns="http://www.w3.org/2000/svg"
+                          viewBox="0 0 16 16"
+                          fill="currentColor"
+                          {...props}
+                        >
+                          <path
+                            className={styles.ascending}
+                            d="m10.677 6.073-2.5-2.5a.25.25 0 0 0-.354 0l-2.5 2.5A.25.25 0 0 0 5.5 6.5h5a.25.25 0 0 0 .177-.427Z"
+                          />
+                          <path
+                            className={styles.descending}
+                            d="m10.677 9.927-2.5 2.5a.25.25 0 0 1-.354 0l-2.5-2.5A.25.25 0 0 1 5.5 9.5h5a.25.25 0 0 1 .177.427Z"
+                          />
+                        </svg>
+                      )}
+                      hideText
+                    >
+                      Sort
+                    </Button>
+                  )}
+                  <div className={styles.divider} />
+                </>
+              )}
+            </Column>
+          )}
+        </TableHeader>
+        <TableBody
+          items={isLoading ? [] : rows}
+          className={styles.tableBody ?? ""}
+          {...(dependencies !== undefined && { dependencies })}
+          {...(!props.hideHeadersInEmptyState &&
+            ("renderEmptyState" in props || "emptyState" in props) && {
+              renderEmptyState:
+                "renderEmptyState" in props
+                  ? props.renderEmptyState
+                  : () => props.emptyState,
+            })}
+        >
+          {isLoading ? (
             <Row
-              className={clsx(styles.row, rowClassName)}
+              id="loading"
+              key="loading"
+              className={styles.row ?? ""}
               columns={columns}
-              data-has-action={row.onAction === undefined ? undefined : ""}
-              {...row}
             >
               {(column: ColumnConfig<T>) => (
-                <Cell {...cellProps(column)}>{data[column.id]}</Cell>
+                <Cell {...cellProps(column)}>
+                  {"loadingSkeleton" in column ? (
+                    column.loadingSkeleton
+                  ) : (
+                    <Skeleton
+                      width={
+                        "loadingSkeletonWidth" in column
+                          ? column.loadingSkeletonWidth
+                          : column.width
+                      }
+                    />
+                  )}
+                </Cell>
               )}
             </Row>
-          )
-        )}
-      </TableBody>
-    </UnstyledTable>
+          ) : (
+            ({ className: rowClassName, data, ...row }: RowConfig<T>) => (
+              <Row
+                className={clsx(styles.row, rowClassName)}
+                columns={columns}
+                data-has-action={row.onAction === undefined ? undefined : ""}
+                {...row}
+              >
+                {(column: ColumnConfig<T>) => (
+                  <Cell {...cellProps(column)}>{data[column.id]}</Cell>
+                )}
+              </Row>
+            )
+          )}
+        </TableBody>
+      </UnstyledTable>
+    )}
   </div>
 );
 

+ 24 - 3
packages/component-library/src/theme.scss

@@ -454,9 +454,18 @@ $color: (
           pallette-color("emerald", 100),
           pallette-color("emerald", 950)
         ),
-      "normal": pallette-color("emerald", 500),
-      "hover": pallette-color("emerald", 600),
-      "active": pallette-color("emerald", 700),
+      "normal":
+        light-dark(
+          pallette-color("emerald", 600),
+          pallette-color("emerald", 500)
+        ),
+      "hover": pallette-color("emerald", 700),
+      "active": pallette-color("emerald", 800),
+      "border":
+        light-dark(
+          pallette-color("emerald", 400),
+          pallette-color("emerald", 800)
+        ),
     ),
     "error": (
       "base": light-dark(pallette-color("red", 600), pallette-color("red", 400)),
@@ -467,10 +476,16 @@ $color: (
       "normal": pallette-color("red", 500),
       "hover": pallette-color("red", 600),
       "active": pallette-color("red", 700),
+      "border":
+        light-dark(pallette-color("red", 400), pallette-color("red", 900)),
     ),
     "neutral": (
       "normal":
         light-dark(pallette-color("steel", 900), pallette-color("steel", 50)),
+      "border":
+        light-dark(pallette-color("stone", 300), pallette-color("steel", 600)),
+      "background":
+        light-dark(pallette-color("white"), pallette-color("steel", 900)),
     ),
     "info": (
       "background":
@@ -484,12 +499,16 @@ $color: (
         light-dark(pallette-color("indigo", 600), pallette-color("indigo", 500)),
       "normal":
         light-dark(pallette-color("indigo", 600), pallette-color("indigo", 400)),
+      "border":
+        light-dark(pallette-color("indigo", 400), pallette-color("indigo", 800)),
     ),
     "warning": (
       "normal":
         light-dark(pallette-color("orange", 600), pallette-color("orange", 400)),
       "background":
         light-dark(pallette-color("orange", 100), pallette-color("orange", 950)),
+      "border":
+        light-dark(pallette-color("orange", 400), pallette-color("orange", 700)),
     ),
     "yellow": (
       "normal": pallette-color("yellow", 500),
@@ -506,6 +525,8 @@ $color: (
         light-dark(pallette-color("violet", 600), pallette-color("violet", 400)),
       "background":
         light-dark(pallette-color("violet", 100), pallette-color("violet", 950)),
+      "border":
+        light-dark(pallette-color("violet", 200), pallette-color("violet", 800)),
     ),
   ),
   "focus":

+ 24 - 0
pnpm-lock.yaml

@@ -183,6 +183,9 @@ catalogs:
     zod:
       specifier: 3.23.8
       version: 3.23.8
+    zod-validation-error:
+      specifier: 3.4.0
+      version: 3.4.0
 
 overrides:
   '@injectivelabs/sdk-ts@1.10.72>@injectivelabs/token-metadata': 1.10.42
@@ -477,6 +480,9 @@ importers:
       zod:
         specifier: 'catalog:'
         version: 3.23.8
+      zod-validation-error:
+        specifier: 'catalog:'
+        version: 3.4.0(zod@3.23.8)
     devDependencies:
       '@cprussin/eslint-config':
         specifier: 'catalog:'
@@ -13858,6 +13864,7 @@ packages:
   encoding-down@6.3.0:
     resolution: {integrity: sha512-QKrV0iKR6MZVJV08QY0wp1e7vF6QbhnbQhb07bwpEyuz4uZiZgPlEGdkCROuFkUwdxlFaiPIhjyarH1ee/3vhw==}
     engines: {node: '>=6'}
+    deprecated: Superseded by abstract-level (https://github.com/Level/community#faq)
 
   encoding-sniffer@0.2.0:
     resolution: {integrity: sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==}
@@ -17050,25 +17057,31 @@ packages:
 
   level-codec@7.0.1:
     resolution: {integrity: sha512-Ua/R9B9r3RasXdRmOtd+t9TCOEIIlts+TN/7XTT2unhDaL6sJn83S3rUyljbr6lVtw49N3/yA0HHjpV6Kzb2aQ==}
+    deprecated: Superseded by level-transcoder (https://github.com/Level/community#faq)
 
   level-codec@9.0.2:
     resolution: {integrity: sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==}
     engines: {node: '>=6'}
+    deprecated: Superseded by level-transcoder (https://github.com/Level/community#faq)
 
   level-concat-iterator@2.0.1:
     resolution: {integrity: sha512-OTKKOqeav2QWcERMJR7IS9CUo1sHnke2C0gkSmcR7QuEtFNLLzHQAvnMw8ykvEcv0Qtkg0p7FOwP1v9e5Smdcw==}
     engines: {node: '>=6'}
+    deprecated: Superseded by abstract-level (https://github.com/Level/community#faq)
 
   level-concat-iterator@3.1.0:
     resolution: {integrity: sha512-BWRCMHBxbIqPxJ8vHOvKUsaO0v1sLYZtjN3K2iZJsRBYtp+ONsY6Jfi6hy9K3+zolgQRryhIn2NRZjZnWJ9NmQ==}
     engines: {node: '>=10'}
+    deprecated: Superseded by abstract-level (https://github.com/Level/community#faq)
 
   level-errors@1.0.5:
     resolution: {integrity: sha512-/cLUpQduF6bNrWuAC4pwtUKA5t669pCsCi2XbmojG2tFeOr9j6ShtdDCtFFQO1DRt+EVZhx9gPzP9G2bUaG4ig==}
+    deprecated: Superseded by abstract-level (https://github.com/Level/community#faq)
 
   level-errors@2.0.1:
     resolution: {integrity: sha512-UVprBJXite4gPS+3VznfgDSU8PTRuVX0NXwoWW50KLxd2yw4Y1t2JUR5In1itQnudZqRMT9DlAM3Q//9NCjCFw==}
     engines: {node: '>=6'}
+    deprecated: Superseded by abstract-level (https://github.com/Level/community#faq)
 
   level-iterator-stream@1.3.1:
     resolution: {integrity: sha512-1qua0RHNtr4nrZBgYlpV0qHHeHpcRRWTxEZJ8xsemoHAXNL5tbooh4tPEEqIqsbWCAJBmUmkwYK/sW5OrFjWWw==}
@@ -17084,6 +17097,7 @@ packages:
   level-packager@5.1.1:
     resolution: {integrity: sha512-HMwMaQPlTC1IlcwT3+swhqf/NUO+ZhXVz6TY1zZIIZlIR0YSn8GtAAWmIvKjNY16ZkEg/JcpAuQskxsXqC0yOQ==}
     engines: {node: '>=6'}
+    deprecated: Superseded by abstract-level (https://github.com/Level/community#faq)
 
   level-supports@1.0.1:
     resolution: {integrity: sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg==}
@@ -23505,6 +23519,12 @@ packages:
     peerDependencies:
       ethers: ^5.7.0
 
+  zod-validation-error@3.4.0:
+    resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==}
+    engines: {node: '>=18.0.0'}
+    peerDependencies:
+      zod: ^3.18.0
+
   zod@3.23.8:
     resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
 
@@ -61892,6 +61912,10 @@ snapshots:
     dependencies:
       ethers: 5.7.2(bufferutil@4.0.7)(utf-8-validate@6.0.3)
 
+  zod-validation-error@3.4.0(zod@3.23.8):
+    dependencies:
+      zod: 3.23.8
+
   zod@3.23.8: {}
 
   zustand@4.4.1(@types/react@19.0.1)(immer@9.0.21)(react@19.0.0):

+ 1 - 0
pnpm-workspace.yaml

@@ -95,4 +95,5 @@ catalog:
   tailwindcss: 3.4.14
   typescript: 5.6.3
   vercel: 37.12.1
+  zod-validation-error: 3.4.0
   zod: 3.23.8