Explorar el Código

feat(insights): implement many tweaks & improvements

- Use 1-day aggregations for scores
- Make median score card not clickable / don't break down averages
- Add explainer tooltips to column headers for score breakdowns
- Add evaluation date in score breakdown explainers
- Filter price feeds by status
- Add price view to component table
Connor Prussin hace 9 meses
padre
commit
911e97258d
Se han modificado 40 ficheros con 1455 adiciones y 1215 borrados
  1. 3 3
      apps/insights/src/app/price-feeds/[slug]/layout.ts
  2. 23 0
      apps/insights/src/components/Explain/index.module.scss
  3. 35 0
      apps/insights/src/components/Explain/index.tsx
  4. 92 0
      apps/insights/src/components/Explanations/index.tsx
  5. 1 1
      apps/insights/src/components/NoResults/index.tsx
  6. 6 8
      apps/insights/src/components/PriceComponentDrawer/index.tsx
  7. 1 1
      apps/insights/src/components/PriceComponentsCard/index.module.scss
  8. 600 0
      apps/insights/src/components/PriceComponentsCard/index.tsx
  9. 4 4
      apps/insights/src/components/PriceFeed/chart-page.tsx
  10. 5 5
      apps/insights/src/components/PriceFeed/layout.tsx
  11. 69 404
      apps/insights/src/components/PriceFeed/publishers-card.tsx
  12. 56 40
      apps/insights/src/components/PriceFeed/publishers.tsx
  13. 4 4
      apps/insights/src/components/PriceFeeds/index.tsx
  14. 0 53
      apps/insights/src/components/Publisher/active-feeds-card.tsx
  15. 22 24
      apps/insights/src/components/Publisher/get-price-feeds.tsx
  16. 1 28
      apps/insights/src/components/Publisher/layout.module.scss
  17. 110 101
      apps/insights/src/components/Publisher/layout.tsx
  18. 45 11
      apps/insights/src/components/Publisher/performance.tsx
  19. 0 8
      apps/insights/src/components/Publisher/price-feeds-card.module.scss
  20. 14 349
      apps/insights/src/components/Publisher/price-feeds-card.tsx
  21. 18 5
      apps/insights/src/components/Publisher/price-feeds.tsx
  22. 28 14
      apps/insights/src/components/Publishers/index.module.scss
  23. 32 56
      apps/insights/src/components/Publishers/index.tsx
  24. 45 20
      apps/insights/src/components/Publishers/publishers-card.tsx
  25. 5 5
      apps/insights/src/components/Root/index.tsx
  26. 4 8
      apps/insights/src/components/Root/search-dialog.tsx
  27. 1 0
      apps/insights/src/components/Score/index.module.scss
  28. 2 2
      apps/insights/src/components/Status/index.tsx
  29. 96 29
      apps/insights/src/services/clickhouse.ts
  30. 41 11
      apps/insights/src/services/pyth.ts
  31. 1 1
      apps/insights/src/static-data/price-feeds.tsx
  32. 27 2
      apps/insights/src/status.ts
  33. 3 1
      packages/component-library/src/Alert/index.tsx
  34. 1 1
      packages/component-library/src/Card/index.module.scss
  35. 1 1
      packages/component-library/src/Drawer/index.module.scss
  36. 33 4
      packages/component-library/src/SingleToggleGroup/index.tsx
  37. 5 6
      packages/component-library/src/StatCard/index.module.scss
  38. 4 4
      packages/component-library/src/StatCard/index.tsx
  39. 16 0
      packages/component-library/src/Table/index.module.scss
  40. 1 1
      packages/component-library/src/Table/index.tsx

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

@@ -1,6 +1,6 @@
 import type { Metadata } from "next";
 
-import { Cluster, getData } from "../../../services/pyth";
+import { Cluster, getFeeds } 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(Cluster.Pythnet);
-  return data.map(({ symbol }) => ({ slug: encodeURIComponent(symbol) }));
+  const feeds = await getFeeds(Cluster.Pythnet);
+  return feeds.map(({ symbol }) => ({ slug: encodeURIComponent(symbol) }));
 };

+ 23 - 0
apps/insights/src/components/Explain/index.module.scss

@@ -0,0 +1,23 @@
+@use "@pythnetwork/component-library/theme";
+
+.trigger {
+  @each $size, $values in theme.$button-sizes {
+    &[data-size="#{$size}"] {
+      margin: -#{theme.map-get-strict($values, "padding")};
+    }
+  }
+}
+
+.description {
+  p {
+    margin: 0;
+  }
+
+  b {
+    font-weight: theme.font-weight("semibold");
+  }
+
+  ul {
+    margin: 0;
+  }
+}

+ 35 - 0
apps/insights/src/components/Explain/index.tsx

@@ -0,0 +1,35 @@
+import { Info } from "@phosphor-icons/react/dist/ssr/Info";
+import { Lightbulb } from "@phosphor-icons/react/dist/ssr/Lightbulb";
+import { Alert, AlertTrigger } from "@pythnetwork/component-library/Alert";
+import { Button } from "@pythnetwork/component-library/Button";
+import type { ComponentProps, ReactNode } from "react";
+
+import styles from "./index.module.scss";
+
+type Props = {
+  size: ComponentProps<typeof Button>["size"];
+  title: string;
+  children: ReactNode;
+};
+
+export const Explain = ({ size, title, children }: Props) => (
+  <AlertTrigger>
+    <Button
+      className={styles.trigger ?? ""}
+      variant="ghost"
+      size={size}
+      beforeIcon={(props) => <Info weight="fill" {...props} />}
+      rounded
+      hideText
+    >
+      Explain {title}
+    </Button>
+    <Alert
+      title={title}
+      icon={<Lightbulb />}
+      bodyClassName={styles.description}
+    >
+      {children}
+    </Alert>
+  </AlertTrigger>
+);

+ 92 - 0
apps/insights/src/components/Explanations/index.tsx

@@ -0,0 +1,92 @@
+import { Button } from "@pythnetwork/component-library/Button";
+import { useMemo } from "react";
+
+import { Explain } from "../Explain";
+import { FormattedDate } from "../FormattedDate";
+
+export const ExplainAverage = ({
+  scoreTime,
+}: {
+  scoreTime?: Date | undefined;
+}) => {
+  return (
+    <Explain size="xs" title="Average Feed Score">
+      <p>
+        Each <b>Price Feed Component</b> that a <b>Publisher</b> provides has an
+        associated <b>Score</b>, which is determined by that component{"'"}s{" "}
+        <b>Uptime</b>, <b>Price Deviation</b>, and <b>Staleness</b>. The{" "}
+        <b>Average Feed Score</b> is the average of the scores for all{" "}
+        <b>Price Feed Components</b>.
+      </p>
+      {scoreTime && <EvaluationTime scoreTime={scoreTime} />}
+      <Button
+        size="xs"
+        variant="solid"
+        href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking"
+        target="_blank"
+      >
+        Learn more
+      </Button>
+    </Explain>
+  );
+};
+
+export const EvaluationTime = ({ scoreTime }: { scoreTime: Date }) => {
+  const startTime = useMemo(() => {
+    const date = new Date(scoreTime);
+    date.setDate(date.getDate() - 1);
+    return date;
+  }, [scoreTime]);
+
+  return (
+    <p>
+      This value is calculated based on feed performance from{" "}
+      <b>
+        <FormattedDate
+          value={startTime}
+          dateStyle="long"
+          timeStyle="long"
+          timeZone="utc"
+        />
+      </b>{" "}
+      to{" "}
+      <b>
+        <FormattedDate
+          value={scoreTime}
+          dateStyle="long"
+          timeStyle="long"
+          timeZone="utc"
+        />
+      </b>
+      .
+    </p>
+  );
+};
+
+export const ExplainActive = () => (
+  <Explain size="xs" title="Active Feeds">
+    <p>
+      This is the number of feeds which the publisher is permissioned for, where
+      the publisher{"'"}s feed has 50% or better uptime over the last day.
+    </p>
+    <NeitherActiveNorInactiveNote />
+  </Explain>
+);
+
+export const ExplainInactive = () => (
+  <Explain size="xs" title="Inactive Feeds">
+    <p>
+      This is the number of feeds which the publisher is permissioned for, but
+      for which the publisher{"'"}s feed has less than 50% uptime over the last
+      day.
+    </p>
+    <NeitherActiveNorInactiveNote />
+  </Explain>
+);
+
+const NeitherActiveNorInactiveNote = () => (
+  <p>
+    Note that a publisher{"'"}s feed may not be considered either active or
+    inactive if Pyth has not yet calculated quality rankings for it.
+  </p>
+);

+ 1 - 1
apps/insights/src/components/NoResults/index.tsx

@@ -33,7 +33,7 @@ export const NoResults = ({ onClearSearch, ...props }: Props) => (
       <p className={styles.body}>
         {"body" in props
           ? props.body
-          : `We couldn't find any results for "${props.query}".`}
+          : `We couldn't find any results for ${props.query === "" ? "your query" : `"${props.query}"`}.`}
       </p>
     </div>
     {onClearSearch && (

+ 6 - 8
apps/insights/src/components/PriceComponentDrawer/index.tsx

@@ -4,6 +4,7 @@ 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 { RouterProvider } from "react-aria";
 import { z } from "zod";
 
 import styles from "./index.module.scss";
@@ -78,14 +79,11 @@ export const PriceComponentDrawer = ({
         <>
           {headingExtra}
           <StatusComponent status={status} />
-          <Button
-            size="sm"
-            variant="outline"
-            onPress={handleOpenFeed}
-            href={navigateHref}
-          >
-            {navigateButtonText}
-          </Button>
+          <RouterProvider navigate={handleOpenFeed}>
+            <Button size="sm" variant="outline" href={navigateHref}>
+              {navigateButtonText}
+            </Button>
+          </RouterProvider>
         </>
       }
       isOpen={isFeedDrawerOpen}

+ 1 - 1
apps/insights/src/components/PriceFeed/publishers-card.module.scss → apps/insights/src/components/PriceComponentsCard/index.module.scss

@@ -1,6 +1,6 @@
 @use "@pythnetwork/component-library/theme";
 
-.publisherName {
+.componentName {
   display: flex;
   flex-flow: row nowrap;
   align-items: center;

+ 600 - 0
apps/insights/src/components/PriceComponentsCard/index.tsx

@@ -0,0 +1,600 @@
+"use client";
+
+import { useLogger } from "@pythnetwork/app-logger";
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { Button } from "@pythnetwork/component-library/Button";
+import { Card } from "@pythnetwork/component-library/Card";
+import { Paginator } from "@pythnetwork/component-library/Paginator";
+import { SearchInput } from "@pythnetwork/component-library/SearchInput";
+import { Select } from "@pythnetwork/component-library/Select";
+import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup";
+import {
+  type RowConfig,
+  type SortDescriptor,
+  Table,
+} from "@pythnetwork/component-library/Table";
+import { useQueryState, parseAsStringEnum, parseAsBoolean } from "nuqs";
+import { type ReactNode, Suspense, useMemo, useCallback } from "react";
+import { useFilter, useCollator } from "react-aria";
+
+import styles from "./index.module.scss";
+import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
+import { Cluster } from "../../services/pyth";
+import {
+  type StatusName,
+  STATUS_NAMES,
+  Status as StatusType,
+  statusNameToStatus,
+} from "../../status";
+import { Explain } from "../Explain";
+import { EvaluationTime } from "../Explanations";
+import { FormattedNumber } from "../FormattedNumber";
+import { LivePrice, LiveConfidence, LiveComponentValue } from "../LivePrices";
+import { NoResults } from "../NoResults";
+import rootStyles from "../Root/index.module.scss";
+import { Score } from "../Score";
+import { Status as StatusComponent } from "../Status";
+
+const SCORE_WIDTH = 32;
+
+type Props = {
+  className?: string | undefined;
+  priceComponents: PriceComponent[];
+  metricsTime?: Date | undefined;
+  nameLoadingSkeleton: ReactNode;
+  label: string;
+  searchPlaceholder: string;
+  onPriceComponentAction: (component: PriceComponent) => void;
+};
+
+type PriceComponent = {
+  id: string;
+  score: number | undefined;
+  symbol: string;
+  uptimeScore: number | undefined;
+  deviationScore: number | undefined;
+  stalledScore: number | undefined;
+  cluster: Cluster;
+  status: StatusType;
+  feedKey: string;
+  publisherKey: string;
+  name: ReactNode;
+  nameAsString: string;
+};
+
+export const PriceComponentsCard = ({
+  priceComponents,
+  onPriceComponentAction,
+  ...props
+}: Props) => (
+  <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
+    <ResolvedPriceComponentsCard
+      priceComponents={priceComponents}
+      onPriceComponentAction={onPriceComponentAction}
+      {...props}
+    />
+  </Suspense>
+);
+
+export const ResolvedPriceComponentsCard = ({
+  priceComponents,
+  onPriceComponentAction,
+  ...props
+}: Props) => {
+  const logger = useLogger();
+  const collator = useCollator();
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const [status, setStatus] = useQueryState(
+    "status",
+    parseAsStringEnum(["", ...Object.values(STATUS_NAMES)]).withDefault(""),
+  );
+  const [showQuality, setShowQuality] = useQueryState(
+    "showQuality",
+    parseAsBoolean.withDefault(false),
+  );
+  const statusType = useMemo(() => statusNameToStatus(status), [status]);
+  const componentsFilteredByStatus = useMemo(
+    () =>
+      statusType === undefined
+        ? priceComponents
+        : priceComponents.filter(
+            (component) => component.status === statusType,
+          ),
+    [statusType, priceComponents],
+  );
+
+  const {
+    search,
+    sortDescriptor,
+    page,
+    pageSize,
+    updateSearch,
+    updateSortDescriptor,
+    updatePage,
+    updatePageSize,
+    paginatedItems,
+    numResults,
+    numPages,
+    mkPageLink,
+  } = useQueryParamFilterPagination(
+    componentsFilteredByStatus,
+    (component, search) => filter.contains(component.nameAsString, search),
+    (a, b, { column, direction }) => {
+      switch (column) {
+        case "score":
+        case "uptimeScore":
+        case "deviationScore":
+        case "stalledScore": {
+          if (a[column] === undefined && b[column] === undefined) {
+            return 0;
+          } else if (a[column] === undefined) {
+            return direction === "descending" ? 1 : -1;
+          } else if (b[column] === undefined) {
+            return direction === "descending" ? -1 : 1;
+          } else {
+            return (
+              (direction === "descending" ? -1 : 1) * (a[column] - b[column])
+            );
+          }
+        }
+
+        case "name": {
+          return (
+            (direction === "descending" ? -1 : 1) *
+            collator.compare(a.nameAsString, b.nameAsString)
+          );
+        }
+
+        case "status": {
+          const resultByStatus = b.status - a.status;
+          const result =
+            resultByStatus === 0
+              ? collator.compare(a.nameAsString, b.nameAsString)
+              : resultByStatus;
+
+          return (direction === "descending" ? -1 : 1) * result;
+        }
+
+        default: {
+          return 0;
+        }
+      }
+    },
+    {
+      defaultPageSize: 20,
+      defaultSort: "name",
+      defaultDescending: false,
+    },
+  );
+
+  const rows = useMemo(
+    () =>
+      paginatedItems.map((component) => ({
+        id: component.id,
+        data: {
+          name: (
+            <div className={styles.componentName}>
+              {component.name}
+              {component.cluster === Cluster.PythtestConformance && (
+                <Badge variant="muted" style="filled" size="xs">
+                  test
+                </Badge>
+              )}
+            </div>
+          ),
+          ...(showQuality
+            ? {
+                score: component.score !== undefined && (
+                  <Score score={component.score} width={SCORE_WIDTH} />
+                ),
+                uptimeScore: component.uptimeScore !== undefined && (
+                  <FormattedNumber
+                    value={component.uptimeScore}
+                    maximumSignificantDigits={5}
+                  />
+                ),
+                deviationScore: component.deviationScore !== undefined && (
+                  <FormattedNumber
+                    value={component.deviationScore}
+                    maximumSignificantDigits={5}
+                  />
+                ),
+                stalledScore: component.stalledScore !== undefined && (
+                  <FormattedNumber
+                    value={component.stalledScore}
+                    maximumSignificantDigits={5}
+                  />
+                ),
+              }
+            : {
+                slot: (
+                  <LiveComponentValue
+                    feedKey={component.feedKey}
+                    publisherKey={component.publisherKey}
+                    field="publishSlot"
+                  />
+                ),
+                price: (
+                  <LivePrice
+                    feedKey={component.feedKey}
+                    publisherKey={component.publisherKey}
+                  />
+                ),
+                confidence: (
+                  <LiveConfidence
+                    feedKey={component.feedKey}
+                    publisherKey={component.publisherKey}
+                  />
+                ),
+              }),
+          status: <StatusComponent status={component.status} />,
+        },
+        onAction: () => {
+          onPriceComponentAction(component);
+        },
+      })),
+    [paginatedItems, showQuality, onPriceComponentAction],
+  );
+
+  const updateStatus = useCallback(
+    (newStatus: StatusName | "") => {
+      updatePage(1);
+      setStatus(newStatus).catch((error: unknown) => {
+        logger.error("Failed to update status", error);
+      });
+    },
+    [updatePage, setStatus, logger],
+  );
+
+  const updateShowQuality = useCallback(
+    (newValue: boolean) => {
+      setShowQuality(newValue).catch((error: unknown) => {
+        logger.error("Failed to update show quality", error);
+      });
+    },
+    [setShowQuality, logger],
+  );
+
+  return (
+    <PriceComponentsCardContents
+      numResults={numResults}
+      search={search}
+      sortDescriptor={sortDescriptor}
+      numPages={numPages}
+      page={page}
+      pageSize={pageSize}
+      onSearchChange={updateSearch}
+      onSortChange={updateSortDescriptor}
+      onPageSizeChange={updatePageSize}
+      onPageChange={updatePage}
+      mkPageLink={mkPageLink}
+      rows={rows}
+      status={status}
+      onStatusChange={updateStatus}
+      showQuality={showQuality}
+      setShowQuality={updateShowQuality}
+      {...props}
+    />
+  );
+};
+
+type PriceComponentsCardProps = Pick<
+  Props,
+  | "className"
+  | "metricsTime"
+  | "nameLoadingSkeleton"
+  | "label"
+  | "searchPlaceholder"
+> &
+  (
+    | { isLoading: true }
+    | {
+        isLoading?: false;
+        numResults: number;
+        search: string;
+        sortDescriptor: SortDescriptor;
+        numPages: number;
+        page: number;
+        pageSize: number;
+        onSearchChange: (newSearch: string) => void;
+        onSortChange: (newSort: SortDescriptor) => void;
+        onPageSizeChange: (newPageSize: number) => void;
+        onPageChange: (newPage: number) => void;
+        mkPageLink: (page: number) => string;
+        status: StatusName | "";
+        onStatusChange: (newStatus: StatusName | "") => void;
+        showQuality: boolean;
+        setShowQuality: (newValue: boolean) => void;
+        rows: RowConfig<string>[];
+      }
+  );
+
+export const PriceComponentsCardContents = ({
+  className,
+  metricsTime,
+  nameLoadingSkeleton,
+  label,
+  searchPlaceholder,
+  ...props
+}: PriceComponentsCardProps) => {
+  const collator = useCollator();
+  return (
+    <Card
+      className={className}
+      title={
+        <>
+          <span>{label}</span>
+          {!props.isLoading && (
+            <Badge style="filled" variant="neutral" size="md">
+              {props.numResults}
+            </Badge>
+          )}
+        </>
+      }
+      toolbar={
+        <>
+          <Select<StatusName | "">
+            label="Status"
+            size="sm"
+            variant="outline"
+            hideLabel
+            options={[
+              "",
+              ...Object.values(STATUS_NAMES).toSorted((a, b) =>
+                collator.compare(a, b),
+              ),
+            ]}
+            {...(props.isLoading
+              ? { isPending: true, buttonLabel: "Status" }
+              : {
+                  show: (value) => (value === "" ? "All" : value),
+                  placement: "bottom end",
+                  buttonLabel: props.status === "" ? "Status" : props.status,
+                  selectedKey: props.status,
+                  onSelectionChange: props.onStatusChange,
+                })}
+          />
+          <SearchInput
+            size="sm"
+            width={60}
+            placeholder={searchPlaceholder}
+            {...(props.isLoading
+              ? { isPending: true, isDisabled: true }
+              : {
+                  value: props.search,
+                  onChange: props.onSearchChange,
+                })}
+          />
+          <SingleToggleGroup
+            {...(!props.isLoading && {
+              selectedKey: props.showQuality ? "quality" : "prices",
+              onSelectionChange: (newValue) => {
+                props.setShowQuality(newValue === "quality");
+              },
+            })}
+            items={[
+              { id: "prices", children: "Prices" },
+              { id: "quality", children: "Quality" },
+            ]}
+          />
+        </>
+      }
+      {...(!props.isLoading && {
+        footer: (
+          <Paginator
+            numPages={props.numPages}
+            currentPage={props.page}
+            onPageChange={props.onPageChange}
+            pageSize={props.pageSize}
+            onPageSizeChange={props.onPageSizeChange}
+            pageSizeOptions={[10, 20, 30, 40, 50]}
+            mkPageLink={props.mkPageLink}
+          />
+        ),
+      })}
+    >
+      <Table
+        label={label}
+        fill
+        rounded
+        stickyHeader={rootStyles.headerHeight}
+        columns={[
+          {
+            id: "name",
+            name: "NAME / ID",
+            alignment: "left",
+            isRowHeader: true,
+            loadingSkeleton: nameLoadingSkeleton,
+            allowsSorting: true,
+          },
+          ...otherColumns(props),
+          {
+            id: "status",
+            width: 20,
+            name: (
+              <>
+                STATUS
+                <Explain size="xs" title="Status">
+                  A publisher{"'"}s feed have one of the following statuses:
+                  <ul>
+                    <li>
+                      <b>Active</b> feeds have better than 50% uptime over the
+                      last day
+                    </li>
+                    <li>
+                      <b>Inactive</b> feeds have worse than 50% uptime over the
+                      last day
+                    </li>
+                    <li>
+                      <b>Unranked</b> feeds have not yet been evaluated by Pyth
+                    </li>
+                  </ul>
+                  {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
+                </Explain>
+              </>
+            ),
+            alignment: "right",
+            allowsSorting: true,
+          },
+        ]}
+        {...(props.isLoading
+          ? { isLoading: true }
+          : {
+              rows: props.rows,
+              sortDescriptor: props.sortDescriptor,
+              onSortChange: props.onSortChange,
+              emptyState: (
+                <NoResults
+                  query={props.search}
+                  onClearSearch={() => {
+                    props.onSearchChange("");
+                    props.onStatusChange("");
+                  }}
+                />
+              ),
+            })}
+      />
+    </Card>
+  );
+};
+
+const otherColumns = ({
+  metricsTime,
+  ...props
+}: { metricsTime?: Date | undefined } & (
+  | { isLoading: true }
+  | { isLoading?: false; showQuality: boolean }
+)) => {
+  if (props.isLoading) {
+    return [];
+  } else {
+    return props.showQuality
+      ? [
+          {
+            id: "uptimeScore",
+            width: 20,
+            name: (
+              <>
+                UPTIME SCORE
+                <Explain size="xs" title="Uptime">
+                  <p>
+                    Uptime is the percentage of time that a publisher{"'"}s feed
+                    is available and active.
+                  </p>
+                  {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
+                  <Button
+                    href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking#uptime-1"
+                    size="xs"
+                    variant="outline"
+                    target="_blank"
+                  >
+                    Read more
+                  </Button>
+                </Explain>
+              </>
+            ),
+            alignment: "center" as const,
+            allowsSorting: true,
+          },
+          {
+            id: "deviationScore",
+            width: 20,
+            name: (
+              <>
+                DEVIATION SCORE
+                <Explain size="xs" title="Deviation">
+                  <p>
+                    Deviation measures how close a publisher{"'"}s price is to
+                    what Pyth believes to be the true market price.
+                  </p>
+                  {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
+                  <Button
+                    href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking#price-deviation-1"
+                    size="xs"
+                    variant="outline"
+                    target="_blank"
+                  >
+                    Read more
+                  </Button>
+                </Explain>
+              </>
+            ),
+            alignment: "center" as const,
+            allowsSorting: true,
+          },
+          {
+            id: "stalledScore",
+            width: 20,
+            name: (
+              <>
+                STALLED SCORE
+                <Explain size="xs" title="Stalled">
+                  <p>
+                    A feed is considered stalled if it is publishing the same
+                    value repeatedly for the price. This score component is
+                    reduced each time a feed is stalled.
+                  </p>
+                  {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
+                  <Button
+                    href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking#lack-of-stalled-prices-1"
+                    size="xs"
+                    variant="outline"
+                    target="_blank"
+                  >
+                    Read more
+                  </Button>
+                </Explain>
+              </>
+            ),
+            alignment: "center" as const,
+            allowsSorting: true,
+          },
+          {
+            id: "score",
+            name: (
+              <>
+                FINAL SCORE
+                <Explain size="xs" title="Uptime">
+                  The final score is calculated by combining the three score
+                  components as follows:
+                  <ul>
+                    <li>
+                      <b>Uptime Score</b> (40% weight)
+                    </li>
+                    <li>
+                      <b>Deviation Score</b> (40% weight)
+                    </li>
+                    <li>
+                      <b>Stalled Score</b> (20% weight)
+                    </li>
+                  </ul>
+                  {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
+                  <Button
+                    href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking"
+                    size="xs"
+                    variant="outline"
+                    target="_blank"
+                  >
+                    Read more
+                  </Button>
+                </Explain>
+              </>
+            ),
+            alignment: "left" as const,
+            width: SCORE_WIDTH,
+            loadingSkeleton: <Score isLoading width={SCORE_WIDTH} />,
+            allowsSorting: true,
+          },
+        ]
+      : [
+          { id: "slot", name: "SLOT", alignment: "left" as const, width: 40 },
+          { id: "price", name: "PRICE", alignment: "left" as const, width: 40 },
+          {
+            id: "confidence",
+            name: "CONFIDENCE INTERVAL",
+            alignment: "left" as const,
+            width: 50,
+          },
+        ];
+  }
+};

+ 4 - 4
apps/insights/src/components/PriceFeed/chart-page.tsx

@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
 
 import { Chart } from "./chart";
 import styles from "./chart-page.module.scss";
-import { Cluster, getData } from "../../services/pyth";
+import { Cluster, getFeeds } from "../../services/pyth";
 
 type Props = {
   params: Promise<{
@@ -12,12 +12,12 @@ type Props = {
 };
 
 export const ChartPage = async ({ params }: Props) => {
-  const [{ slug }, data] = await Promise.all([
+  const [{ slug }, feeds] = await Promise.all([
     params,
-    getData(Cluster.Pythnet),
+    getFeeds(Cluster.Pythnet),
   ]);
   const symbol = decodeURIComponent(slug);
-  const feed = data.find((item) => item.symbol === symbol);
+  const feed = feeds.find((item) => item.symbol === symbol);
 
   return feed ? (
     <Card title="Chart" className={styles.chartCard}>

+ 5 - 5
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 { Cluster, getData } from "../../services/pyth";
+import { Cluster, getFeeds } from "../../services/pyth";
 import { FeedKey } from "../FeedKey";
 import {
   LivePrice,
@@ -38,12 +38,12 @@ type Props = {
 };
 
 export const PriceFeedLayout = async ({ children, params }: Props) => {
-  const [{ slug }, data] = await Promise.all([
+  const [{ slug }, fees] = await Promise.all([
     params,
-    getData(Cluster.Pythnet),
+    getFeeds(Cluster.Pythnet),
   ]);
   const symbol = decodeURIComponent(slug);
-  const feed = data.find((item) => item.symbol === symbol);
+  const feed = fees.find((item) => item.symbol === symbol);
 
   return feed ? (
     <div className={styles.priceFeedLayout}>
@@ -65,7 +65,7 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
         </div>
         <div className={styles.headerRow}>
           <PriceFeedSelect
-            feeds={data
+            feeds={fees
               .filter((feed) => feed.symbol !== symbol)
               .map((feed) => ({
                 id: feed.symbol,

+ 69 - 404
apps/insights/src/components/PriceFeed/publishers-card.tsx

@@ -1,278 +1,99 @@
 "use client";
 
 import { useLogger } from "@pythnetwork/app-logger";
-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";
-import { Switch } from "@pythnetwork/component-library/Switch";
 import {
-  type RowConfig,
-  type SortDescriptor,
-  Table,
-} from "@pythnetwork/component-library/Table";
-import { useQueryState, parseAsString, parseAsBoolean } from "nuqs";
-import { type ReactNode, Suspense, useMemo, useCallback } from "react";
-import { useFilter, useCollator } from "react-aria";
+  useQueryState,
+  parseAsString, // , parseAsBoolean
+} from "nuqs";
+import { type ComponentProps, Suspense, useCallback, useMemo } from "react";
 
-import styles from "./publishers-card.module.scss";
-import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
-import { Cluster } from "../../services/pyth";
-import { Status as StatusType } from "../../status";
-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;
-  publishers: Publisher[];
+import {
+  PriceComponentsCardContents,
+  ResolvedPriceComponentsCard,
+} from "../PriceComponentsCard";
+// import { Cluster } from "../../services/pyth";
+
+type Publisher = ComponentProps<
+  typeof ResolvedPriceComponentsCard
+>["priceComponents"][number] & {
+  rank?: number | undefined;
 };
 
-type Publisher = {
-  id: string;
-  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 }
-);
+type Props = Omit<
+  ComponentProps<typeof ResolvedPriceComponentsCard>,
+  "onPriceComponentAction"
+>;
 
-export const PublishersCard = ({ publishers, ...props }: Props) => (
-  <Suspense fallback={<PublishersCardContents isLoading {...props} />}>
-    <ResolvedPublishersCard publishers={publishers} {...props} />
+export const PublishersCard = ({ priceComponents, ...props }: Props) => (
+  <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
+    <ResolvedPublishersCard priceComponents={priceComponents} {...props} />
   </Suspense>
 );
 
-const ResolvedPublishersCard = ({
-  symbol,
-  feedKey,
-  publishers,
-  ...props
-}: Props) => {
+const ResolvedPublishersCard = ({ priceComponents, ...props }: Props) => {
+  // const logger = useLogger();
   const { handleClose, selectedPublisher, updateSelectedPublisherKey } =
-    usePublisherDrawer(publishers);
-  const logger = useLogger();
-  const [includeTestFeeds, setIncludeTestFeeds] = useQueryState(
-    "includeTestFeeds",
-    parseAsBoolean.withDefault(false),
-  );
-  const collator = useCollator();
-  const filter = useFilter({ sensitivity: "base", usage: "search" });
-  const filteredPublishers = useMemo(
-    () =>
-      includeTestFeeds
-        ? publishers
-        : publishers.filter(
-            (publisher) => publisher.cluster === Cluster.Pythnet,
-          ),
-    [includeTestFeeds, publishers],
-  );
-
-  const {
-    search,
-    sortDescriptor,
-    page,
-    pageSize,
-    updateSearch,
-    updateSortDescriptor,
-    updatePage,
-    updatePageSize,
-    paginatedItems,
-    numResults,
-    numPages,
-    mkPageLink,
-  } = useQueryParamFilterPagination(
-    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":
-        case "deviationPenalty": {
-          if (a[column] === undefined && b[column] === undefined) {
-            return 0;
-          } else if (a[column] === undefined) {
-            return direction === "descending" ? 1 : -1;
-          } else if (b[column] === undefined) {
-            return direction === "descending" ? -1 : 1;
-          } else {
-            return (
-              (direction === "descending" ? -1 : 1) * (a[column] - b[column])
-            );
-          }
-        }
-
-        case "name": {
-          return (
-            (direction === "descending" ? -1 : 1) *
-            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 0;
-        }
-      }
-    },
-    {
-      defaultPageSize: 20,
-      defaultSort: "score",
-      defaultDescending: true,
+    usePublisherDrawer(priceComponents);
+  const onPriceComponentAction = useCallback(
+    ({ publisherKey }: Publisher) => {
+      updateSelectedPublisherKey(publisherKey);
     },
+    [updateSelectedPublisherKey],
   );
-
-  const rows = useMemo(
-    () =>
-      paginatedItems.map(
-        ({
-          id,
-          publisherKey,
-          score,
-          uptimeScore,
-          deviationPenalty,
-          deviationScore,
-          stalledPenalty,
-          stalledScore,
-          cluster,
-          status,
-          ...publisher
-        }) => ({
-          id,
-          onAction: () => {
-            updateSelectedPublisherKey(publisherKey);
-          },
-          data: {
-            score: score !== undefined && (
-              <Score score={score} width={SCORE_WIDTH} />
-            ),
-            name: (
-              <div className={styles.publisherName}>
-                <PublisherTag
-                  publisherKey={publisherKey}
-                  {...(publisher.name && {
-                    name: publisher.name,
-                    icon: publisher.icon,
-                  })}
-                />
-                {cluster === Cluster.PythtestConformance && (
-                  <Badge variant="muted" style="filled" size="xs">
-                    test
-                  </Badge>
-                )}
-              </div>
-            ),
-            uptimeScore: uptimeScore && (
-              <FormattedNumber
-                value={uptimeScore}
-                maximumSignificantDigits={5}
-              />
-            ),
-            deviationPenalty: deviationPenalty && (
-              <FormattedNumber
-                value={deviationPenalty}
-                maximumSignificantDigits={5}
-              />
-            ),
-            deviationScore: deviationScore && (
-              <FormattedNumber
-                value={deviationScore}
-                maximumSignificantDigits={5}
-              />
-            ),
-            stalledPenalty: stalledPenalty && (
-              <FormattedNumber
-                value={stalledPenalty}
-                maximumSignificantDigits={5}
-              />
-            ),
-            stalledScore: stalledScore && (
-              <FormattedNumber
-                value={stalledScore}
-                maximumSignificantDigits={5}
-              />
-            ),
-            status: <StatusComponent status={status} />,
-          },
-        }),
-      ),
-    [paginatedItems, updateSelectedPublisherKey],
-  );
-
-  const updateIncludeTestFeeds = useCallback(
-    (newValue: boolean) => {
-      setIncludeTestFeeds(newValue).catch((error: unknown) => {
-        logger.error(
-          "Failed to update include test components query param",
-          error,
-        );
-      });
-    },
-    [setIncludeTestFeeds, logger],
-  );
+  // const [includeTestFeeds, setIncludeTestFeeds] = useQueryState(
+  //   "includeTestFeeds",
+  //   parseAsBoolean.withDefault(false),
+  // );
+  // const componentsFilteredByCluster = useMemo(
+  //   () =>
+  //     includeTestFeeds
+  //       ? priceComponents
+  //       : priceComponents.filter(
+  //           (component) => component.cluster === Cluster.Pythnet,
+  //         ),
+  //   [includeTestFeeds, priceComponents],
+  // );
+  // const updateIncludeTestFeeds = useCallback(
+  //   (newValue: boolean) => {
+  //     setIncludeTestFeeds(newValue).catch((error: unknown) => {
+  //       logger.error(
+  //         "Failed to update include test components query param",
+  //         error,
+  //       );
+  //     });
+  //   },
+  //   [setIncludeTestFeeds, logger],
+  // );
+  //         <Switch
+  //           {...(props.isLoading
+  //             ? { isLoading: true }
+  //             : {
+  //                 isSelected: props.includeTestFeeds,
+  //                 onChange: props.onIncludeTestFeedsChange,
+  //               })}
+  //         >
+  //           Show test feeds
+  //         </Switch>
 
   return (
     <>
-      <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}
+      <ResolvedPriceComponentsCard
+        onPriceComponentAction={onPriceComponentAction}
+        // priceComponents={componentsFilteredByCluster}
+        priceComponents={priceComponents}
         {...props}
       />
       {selectedPublisher && (
         <PriceComponentDrawer
           publisherKey={selectedPublisher.publisherKey}
           onClose={handleClose}
-          symbol={symbol}
-          feedKey={feedKey}
+          symbol={selectedPublisher.symbol}
+          feedKey={selectedPublisher.feedKey}
           rank={selectedPublisher.rank}
           score={selectedPublisher.score}
           status={selectedPublisher.status}
-          title={<PublisherTag {...selectedPublisher} />}
+          title={selectedPublisher.name}
           navigateButtonText="Open Publisher"
           navigateHref={`/publishers/${selectedPublisher.publisherKey}`}
         />
@@ -281,162 +102,6 @@ const ResolvedPublishersCard = ({
   );
 };
 
-type PublishersCardProps = Pick<Props, "className"> &
-  (
-    | { isLoading: true }
-    | {
-        isLoading?: false;
-        numResults: number;
-        search: string;
-        sortDescriptor: SortDescriptor;
-        numPages: number;
-        page: number;
-        pageSize: number;
-        includeTestFeeds: boolean;
-        onIncludeTestFeedsChange: (newValue: boolean) => void;
-        onSearchChange: (newSearch: string) => void;
-        onSortChange: (newSort: SortDescriptor) => void;
-        onPageSizeChange: (newPageSize: number) => void;
-        onPageChange: (newPage: number) => void;
-        mkPageLink: (page: number) => string;
-        rows: RowConfig<
-          | "score"
-          | "name"
-          | "uptimeScore"
-          | "deviationScore"
-          | "deviationPenalty"
-          | "stalledScore"
-          | "stalledPenalty"
-          | "status"
-        >[];
-      }
-  );
-
-const PublishersCardContents = ({
-  className,
-  ...props
-}: PublishersCardProps) => (
-  <Card
-    className={className}
-    title="Publishers"
-    toolbar={
-      <>
-        <Switch
-          {...(props.isLoading
-            ? { isLoading: true }
-            : {
-                isSelected: props.includeTestFeeds,
-                onChange: props.onIncludeTestFeedsChange,
-              })}
-        >
-          Show test feeds
-        </Switch>
-        <SearchInput
-          size="sm"
-          width={60}
-          placeholder="Publisher key or name"
-          {...(props.isLoading
-            ? { isPending: true, isDisabled: true }
-            : {
-                value: props.search,
-                onChange: props.onSearchChange,
-              })}
-        />
-      </>
-    }
-    {...(!props.isLoading && {
-      footer: (
-        <Paginator
-          numPages={props.numPages}
-          currentPage={props.page}
-          onPageChange={props.onPageChange}
-          pageSize={props.pageSize}
-          onPageSizeChange={props.onPageSizeChange}
-          pageSizeOptions={[10, 20, 30, 40, 50]}
-          mkPageLink={props.mkPageLink}
-        />
-      ),
-    })}
-  >
-    <Table
-      label="Publishers"
-      fill
-      rounded
-      stickyHeader={rootStyles.headerHeight}
-      columns={[
-        {
-          id: "score",
-          name: "SCORE",
-          alignment: "center",
-          width: SCORE_WIDTH,
-          loadingSkeleton: <Score isLoading width={SCORE_WIDTH} />,
-          allowsSorting: true,
-        },
-        {
-          id: "name",
-          name: "NAME / ID",
-          alignment: "left",
-          isRowHeader: true,
-          loadingSkeleton: <PublisherTag isLoading />,
-          allowsSorting: true,
-          fill: true,
-        },
-        {
-          id: "uptimeScore",
-          name: "UPTIME SCORE",
-          alignment: "center",
-          allowsSorting: true,
-        },
-        {
-          id: "deviationScore",
-          name: "DEVIATION SCORE",
-          alignment: "center",
-          allowsSorting: true,
-        },
-        {
-          id: "deviationPenalty",
-          name: "DEVIATION PENALTY",
-          alignment: "center",
-          allowsSorting: true,
-        },
-        {
-          id: "stalledScore",
-          name: "STALLED SCORE",
-          alignment: "center",
-          allowsSorting: true,
-        },
-        {
-          id: "stalledPenalty",
-          name: "STALLED PENALTY",
-          alignment: "center",
-          allowsSorting: true,
-        },
-        {
-          id: "status",
-          name: "STATUS",
-          alignment: "right",
-          allowsSorting: true,
-        },
-      ]}
-      {...(props.isLoading
-        ? { isLoading: true }
-        : {
-            rows: props.rows,
-            sortDescriptor: props.sortDescriptor,
-            onSortChange: props.onSortChange,
-            emptyState: (
-              <NoResults
-                query={props.search}
-                onClearSearch={() => {
-                  props.onSearchChange("");
-                }}
-              />
-            ),
-          })}
-    />
-  </Card>
-);
-
 const usePublisherDrawer = (publishers: Publisher[]) => {
   const logger = useLogger();
   const [selectedPublisherKey, setSelectedPublisher] = useQueryState(

+ 56 - 40
apps/insights/src/components/PriceFeed/publishers.tsx

@@ -3,9 +3,15 @@ import { notFound } from "next/navigation";
 
 import { PublishersCard } from "./publishers-card";
 import { getRankingsBySymbol } from "../../services/clickhouse";
-import { Cluster, ClusterToName, getData } from "../../services/pyth";
+import {
+  Cluster,
+  ClusterToName,
+  getFeeds,
+  getPublishersForFeed,
+} from "../../services/pyth";
 import { getStatus } from "../../status";
 import { PublisherIcon } from "../PublisherIcon";
+import { PublisherTag } from "../PublisherTag";
 
 type Props = {
   params: Promise<{
@@ -16,66 +22,76 @@ type Props = {
 export const Publishers = async ({ params }: Props) => {
   const { slug } = await params;
   const symbol = decodeURIComponent(slug);
-  const [pythnetData, pythnetPublishers, pythtestConformancePublishers] =
+  const [feeds, pythnetPublishers, pythtestConformancePublishers] =
     await Promise.all([
-      getData(Cluster.Pythnet),
+      getFeeds(Cluster.Pythnet),
       getPublishers(Cluster.Pythnet, symbol),
       getPublishers(Cluster.PythtestConformance, symbol),
     ]);
-  const feed = pythnetData.find((item) => item.symbol === symbol);
+  const feed = feeds.find((feed) => feed.symbol === symbol);
+  const publishers = [...pythnetPublishers, ...pythtestConformancePublishers];
+  const metricsTime = pythnetPublishers.find(
+    (publisher) => publisher.ranking !== undefined,
+  )?.ranking?.time;
 
-  return feed !== undefined &&
-    (pythnetPublishers !== undefined ||
-      pythtestConformancePublishers !== undefined) ? (
+  return feed === undefined ? (
+    notFound()
+  ) : (
     <PublishersCard
-      symbol={symbol}
-      feedKey={feed.product.price_account}
-      publishers={[
-        ...(pythnetPublishers ?? []),
-        ...(pythtestConformancePublishers ?? []),
-      ]}
+      label="Publishers"
+      searchPlaceholder="Publisher key or name"
+      metricsTime={metricsTime}
+      nameLoadingSkeleton={<PublisherTag isLoading />}
+      priceComponents={publishers.map(
+        ({ ranking, publisher, status, cluster, knownPublisher }) => ({
+          id: `${publisher}-${ClusterToName[Cluster.Pythnet]}`,
+          feedKey: feed.product.price_account,
+          score: ranking?.final_score,
+          uptimeScore: ranking?.uptime_score,
+          deviationScore: ranking?.deviation_score,
+          stalledScore: ranking?.stalled_score,
+          cluster,
+          status,
+          publisherKey: publisher,
+          symbol,
+          rank: ranking?.final_rank,
+          name: (
+            <PublisherTag
+              publisherKey={publisher}
+              {...(knownPublisher && {
+                name: knownPublisher.name,
+                icon: <PublisherIcon knownPublisher={knownPublisher} />,
+              })}
+            />
+          ),
+          nameAsString: `${knownPublisher?.name ?? ""}${publisher}`,
+        }),
+      )}
     />
-  ) : (
-    notFound()
   );
 };
 
 const getPublishers = async (cluster: Cluster, symbol: string) => {
-  const [data, rankings] = await Promise.all([
-    getData(cluster),
+  const [publishers, rankings] = await Promise.all([
+    getPublishersForFeed(cluster, symbol),
     getRankingsBySymbol(symbol),
   ]);
 
-  return data
-    .find((feed) => feed.symbol === symbol)
-    ?.price.priceComponents.map(({ publisher }) => {
+  return (
+    publishers?.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,
+        ranking,
+        publisher,
         status: getStatus(ranking),
-        ...(knownPublisher && {
-          name: knownPublisher.name,
-          icon: <PublisherIcon knownPublisher={knownPublisher} />,
-        }),
+        cluster,
+        knownPublisher: lookupPublisher(publisher),
       };
-    });
+    }) ?? []
+  );
 };

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

@@ -1,7 +1,7 @@
 import { ArrowLineDown } from "@phosphor-icons/react/dist/ssr/ArrowLineDown";
 import { ArrowSquareOut } from "@phosphor-icons/react/dist/ssr/ArrowSquareOut";
+import { ArrowsOutSimple } from "@phosphor-icons/react/dist/ssr/ArrowsOutSimple";
 import { ClockCountdown } from "@phosphor-icons/react/dist/ssr/ClockCountdown";
-import { Info } from "@phosphor-icons/react/dist/ssr/Info";
 import { StackPlus } from "@phosphor-icons/react/dist/ssr/StackPlus";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Button } from "@pythnetwork/component-library/Button";
@@ -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 { Cluster, getData } from "../../services/pyth";
+import { Cluster, getFeeds } from "../../services/pyth";
 import { priceFeeds as priceFeedsStaticConfig } from "../../static-data/price-feeds";
 import { activeChains } from "../../static-data/stats";
 import { LivePrice } from "../LivePrices";
@@ -75,7 +75,7 @@ export const PriceFeeds = async () => {
             <StatCard
               header="Asset Classes"
               stat={Object.keys(numFeedsByAssetClass).length}
-              corner={<Info weight="fill" />}
+              corner={<ArrowsOutSimple />}
             />
           </AssetClassesDrawer>
         </section>
@@ -200,7 +200,7 @@ const FeaturedFeedsCard = <T extends ElementType>({
 );
 
 const getPriceFeeds = async () => {
-  const priceFeeds = await getData(Cluster.Pythnet);
+  const priceFeeds = await getFeeds(Cluster.Pythnet);
   const activeFeeds = priceFeeds.filter((feed) => isActive(feed));
   const comingSoon = priceFeeds.filter((feed) => !isActive(feed));
   return { activeFeeds, comingSoon };

+ 0 - 53
apps/insights/src/components/Publisher/active-feeds-card.tsx

@@ -1,53 +0,0 @@
-"use client";
-
-import { StatCard } from "@pythnetwork/component-library/StatCard";
-import { useSelectedLayoutSegment } from "next/navigation";
-
-import { FormattedNumber } from "../FormattedNumber";
-import { Meter } from "../Meter";
-
-type Props = {
-  publisherKey: string;
-  activeFeeds: number;
-  totalFeeds: number;
-};
-
-export const ActiveFeedsCard = ({
-  publisherKey,
-  activeFeeds,
-  totalFeeds,
-}: Props) => {
-  const layoutSegment = useSelectedLayoutSegment();
-
-  return (
-    <StatCard
-      header1="Active Feeds"
-      header2="Inactive Feeds"
-      stat1={activeFeeds}
-      stat2={totalFeeds - activeFeeds}
-      miniStat1={
-        <>
-          <FormattedNumber
-            maximumFractionDigits={1}
-            value={(100 * activeFeeds) / totalFeeds}
-          />
-          %
-        </>
-      }
-      miniStat2={
-        <>
-          <FormattedNumber
-            maximumFractionDigits={1}
-            value={(100 * (totalFeeds - activeFeeds)) / totalFeeds}
-          />
-          %
-        </>
-      }
-      {...(layoutSegment !== "price-feeds" && {
-        href: `/publishers/${publisherKey}/price-feeds`,
-      })}
-    >
-      <Meter value={activeFeeds} maxValue={totalFeeds} label="Active Feeds" />
-    </StatCard>
-  );
-};

+ 22 - 24
apps/insights/src/components/Publisher/get-price-feeds.tsx

@@ -1,31 +1,29 @@
 import { getRankingsByPublisher } from "../../services/clickhouse";
-import { type Cluster, ClusterToName, getData } from "../../services/pyth";
+import {
+  type Cluster,
+  ClusterToName,
+  getFeedsForPublisher,
+} from "../../services/pyth";
 import { getStatus } from "../../status";
 
 export const getPriceFeeds = async (cluster: Cluster, key: string) => {
-  const [data, rankings] = await Promise.all([
-    getData(cluster),
+  const [feeds, rankings] = await Promise.all([
+    getFeedsForPublisher(cluster, key),
     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),
-      };
-    });
+  return feeds.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),
+    };
+  });
 };

+ 1 - 28
apps/insights/src/components/Publisher/layout.module.scss

@@ -30,15 +30,10 @@
         width: 0;
       }
 
-      .medianScoreChart svg {
+      .averageScoreChart svg {
         cursor: pointer;
       }
 
-      .publisherRankingExplainButton {
-        margin-top: -#{theme.button-padding("xs", false)};
-        margin-right: -#{theme.button-padding("xs", false)};
-      }
-
       .activeDate {
         color: theme.color("muted");
       }
@@ -81,14 +76,6 @@
   }
 }
 
-.publisherRankingExplainDescription {
-  margin: 0;
-
-  b {
-    font-weight: theme.font-weight("semibold");
-  }
-}
-
 .oisDrawer {
   .oisDrawerBody {
     display: grid;
@@ -120,17 +107,3 @@
     align-items: center;
   }
 }
-
-.medianScoreDrawer {
-  .medianScoreDrawerFooter {
-    display: flex;
-    flex-flow: row nowrap;
-    justify-content: flex-end;
-  }
-
-  .medianScoreDrawerBody {
-    display: flex;
-    flex-flow: column nowrap;
-    gap: theme.spacing(6);
-  }
-}

+ 110 - 101
apps/insights/src/components/Publisher/layout.tsx

@@ -1,36 +1,39 @@
+import { ArrowsOutSimple } from "@phosphor-icons/react/dist/ssr/ArrowsOutSimple";
 import { BookOpenText } from "@phosphor-icons/react/dist/ssr/BookOpenText";
 import { Browsers } from "@phosphor-icons/react/dist/ssr/Browsers";
-import { Info } from "@phosphor-icons/react/dist/ssr/Info";
-import { Lightbulb } from "@phosphor-icons/react/dist/ssr/Lightbulb";
-import { Ranking } from "@phosphor-icons/react/dist/ssr/Ranking";
 import { ShieldChevron } from "@phosphor-icons/react/dist/ssr/ShieldChevron";
-import { Alert, AlertTrigger } from "@pythnetwork/component-library/Alert";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Breadcrumbs } from "@pythnetwork/component-library/Breadcrumbs";
 import { Button } from "@pythnetwork/component-library/Button";
 import { DrawerTrigger, Drawer } from "@pythnetwork/component-library/Drawer";
 import { InfoBox } from "@pythnetwork/component-library/InfoBox";
+import { Link } from "@pythnetwork/component-library/Link";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
 import { lookup } from "@pythnetwork/known-publishers";
 import { notFound } from "next/navigation";
 import type { ReactNode } from "react";
 
-import { ActiveFeedsCard } from "./active-feeds-card";
 import { getPriceFeeds } from "./get-price-feeds";
 import styles from "./layout.module.scss";
 import { OisApyHistory } from "./ois-apy-history";
 import { PriceFeedDrawerProvider } from "./price-feed-drawer-provider";
 import {
   getPublisherRankingHistory,
-  getPublisherMedianScoreHistory,
+  getPublisherAverageScoreHistory,
+  getPublishers,
 } from "../../services/clickhouse";
 import { getPublisherCaps } from "../../services/hermes";
-import { Cluster, getTotalFeedCount } from "../../services/pyth";
+import { Cluster } from "../../services/pyth";
 import { getPublisherPoolData } from "../../services/staking";
-import { Status } from "../../status";
 import { ChangePercent } from "../ChangePercent";
 import { ChangeValue } from "../ChangeValue";
 import { ChartCard } from "../ChartCard";
+import { Explain } from "../Explain";
+import {
+  ExplainAverage,
+  ExplainActive,
+  ExplainInactive,
+} from "../Explanations";
 import { FormattedDate } from "../FormattedDate";
 import { FormattedNumber } from "../FormattedNumber";
 import { FormattedTokens } from "../FormattedTokens";
@@ -39,7 +42,6 @@ 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";
@@ -55,26 +57,27 @@ export const PublishersLayout = async ({ children, params }: Props) => {
   const { key } = await params;
   const [
     rankingHistory,
-    medianScoreHistory,
-    totalFeedsCount,
+    averageScoreHistory,
     oisStats,
     priceFeeds,
+    publishers,
   ] = await Promise.all([
     getPublisherRankingHistory(key),
-    getPublisherMedianScoreHistory(key),
-    getTotalFeedCount(Cluster.Pythnet),
+    getPublisherAverageScoreHistory(key),
     getOisStats(key),
     getPriceFeeds(Cluster.Pythnet, key),
+    getPublishers(),
   ]);
 
   const currentRanking = rankingHistory.at(-1);
   const previousRanking = rankingHistory.at(-2);
 
-  const currentMedianScore = medianScoreHistory.at(-1);
-  const previousMedianScore = medianScoreHistory.at(-2);
+  const currentAverageScore = averageScoreHistory.at(-1);
+  const previousAverageScore = averageScoreHistory.at(-2);
   const knownPublisher = lookup(key);
+  const publisher = publishers.find((publisher) => publisher.key === key);
 
-  return currentRanking && currentMedianScore ? (
+  return publisher && currentRanking && currentAverageScore ? (
     <PriceFeedDrawerProvider
       publisherKey={key}
       priceFeeds={priceFeeds.map(({ feed, ranking, status }) => ({
@@ -115,25 +118,13 @@ export const PublishersLayout = async ({ children, params }: Props) => {
               variant="primary"
               header="Publisher Ranking"
               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>
+                <Explain size="xs" title="Publisher Ranking">
+                  <p>
+                    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>
+                </Explain>
               }
               data={rankingHistory.map(({ timestamp, rank }) => ({
                 x: timestamp,
@@ -158,76 +149,94 @@ export const PublishersLayout = async ({ children, params }: Props) => {
                 ),
               })}
             />
-            <DrawerTrigger>
-              <ChartCard
-                header="Median Score"
-                chartClassName={styles.medianScoreChart}
-                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={
+            <ChartCard
+              header="Average Score"
+              chartClassName={styles.averageScoreChart}
+              corner={<ExplainAverage />}
+              data={averageScoreHistory.map(({ time, averageScore }) => ({
+                x: time,
+                y: averageScore,
+                displayX: (
+                  <span className={styles.activeDate}>
+                    <FormattedDate value={time} />
+                  </span>
+                ),
+                displayY: (
                   <FormattedNumber
                     maximumSignificantDigits={5}
-                    value={currentMedianScore.score}
+                    value={averageScore}
                   />
-                }
-                {...(previousMedianScore && {
-                  miniStat: (
-                    <ChangePercent
-                      currentValue={currentMedianScore.score}
-                      previousValue={previousMedianScore.score}
-                    />
-                  ),
-                })}
-              />
-              <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>
-                }
-              >
-                <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
+                ),
+              }))}
+              stat={
+                <FormattedNumber
+                  maximumSignificantDigits={5}
+                  value={currentAverageScore.averageScore}
+                />
               }
-              totalFeeds={totalFeedsCount}
+              {...(previousAverageScore && {
+                miniStat: (
+                  <ChangePercent
+                    currentValue={currentAverageScore.averageScore}
+                    previousValue={previousAverageScore.averageScore}
+                  />
+                ),
+              })}
             />
+            <StatCard
+              header1={
+                <>
+                  Active Feeds
+                  <ExplainActive />
+                </>
+              }
+              header2={
+                <>
+                  <ExplainInactive />
+                  Inactive Feeds
+                </>
+              }
+              stat1={
+                <Link
+                  href={`/publishers/${key}/price-feeds?status=Active`}
+                  invert
+                >
+                  {publisher.activeFeeds}
+                </Link>
+              }
+              stat2={
+                <Link
+                  href={`/publishers/${key}/price-feeds?status=Inactive`}
+                  invert
+                >
+                  {publisher.inactiveFeeds}
+                </Link>
+              }
+              miniStat1={
+                <>
+                  <FormattedNumber
+                    maximumFractionDigits={1}
+                    value={(100 * publisher.activeFeeds) / priceFeeds.length}
+                  />
+                  %
+                </>
+              }
+              miniStat2={
+                <>
+                  <FormattedNumber
+                    maximumFractionDigits={1}
+                    value={(100 * publisher.inactiveFeeds) / priceFeeds.length}
+                  />
+                  %
+                </>
+              }
+            >
+              <Meter
+                value={publisher.activeFeeds}
+                maxValue={priceFeeds.length}
+                label="Active Feeds"
+              />
+            </StatCard>
             <DrawerTrigger>
               <StatCard
                 header="OIS Pool Allocation"
@@ -250,7 +259,7 @@ export const PublishersLayout = async ({ children, params }: Props) => {
                     %
                   </span>
                 }
-                corner={<Info weight="fill" />}
+                corner={<ArrowsOutSimple />}
               >
                 <Meter
                   value={Number(oisStats.poolUtilization)}

+ 45 - 11
apps/insights/src/components/Publisher/performance.tsx

@@ -4,6 +4,7 @@ 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 { Link } from "@pythnetwork/component-library/Link";
 import { Table } from "@pythnetwork/component-library/Table";
 import { lookup } from "@pythnetwork/known-publishers";
 import { notFound } from "next/navigation";
@@ -12,8 +13,13 @@ import { getPriceFeeds } from "./get-price-feeds";
 import styles from "./performance.module.scss";
 import { TopFeedsTable } from "./top-feeds-table";
 import { getPublishers } from "../../services/clickhouse";
-import { Cluster, getTotalFeedCount } from "../../services/pyth";
+import { Cluster } from "../../services/pyth";
 import { Status } from "../../status";
+import {
+  ExplainActive,
+  ExplainInactive,
+  ExplainAverage,
+} from "../Explanations";
 import { NoResults } from "../NoResults";
 import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
@@ -32,10 +38,9 @@ type Props = {
 
 export const Performance = async ({ params }: Props) => {
   const { key } = await params;
-  const [publishers, priceFeeds, totalFeeds] = await Promise.all([
+  const [publishers, priceFeeds] = await Promise.all([
     getPublishers(),
     getPriceFeeds(Cluster.Pythnet, key),
-    getTotalFeedCount(Cluster.Pythnet),
   ]);
   const slicedPublishers = sliceAround(
     publishers,
@@ -66,19 +71,34 @@ export const Performance = async ({ params }: Props) => {
             },
             {
               id: "activeFeeds",
-              name: "ACTIVE FEEDS",
+              name: (
+                <>
+                  ACTIVE FEEDS
+                  <ExplainActive />
+                </>
+              ),
               alignment: "center",
               width: 30,
             },
             {
               id: "inactiveFeeds",
-              name: "INACTIVE FEEDS",
+              name: (
+                <>
+                  INACTIVE FEEDS
+                  <ExplainInactive />
+                </>
+              ),
               alignment: "center",
               width: 30,
             },
             {
-              id: "medianScore",
-              name: "MEDIAN SCORE",
+              id: "averageScore",
+              name: (
+                <>
+                  AVERAGE SCORE
+                  <ExplainAverage scoreTime={publishers[0]?.scoreTime} />
+                </>
+              ),
               alignment: "right",
               width: PUBLISHER_SCORE_WIDTH,
             },
@@ -93,12 +113,26 @@ export const Performance = async ({ params }: Props) => {
                     {publisher.rank}
                   </Ranking>
                 ),
-                activeFeeds: publisher.numSymbols,
-                inactiveFeeds: totalFeeds - publisher.numSymbols,
-                medianScore: (
+                activeFeeds: (
+                  <Link
+                    href={`/publishers/${publisher.key}/price-feeds?status=Active`}
+                    invert
+                  >
+                    {publisher.activeFeeds}
+                  </Link>
+                ),
+                inactiveFeeds: (
+                  <Link
+                    href={`/publishers/${publisher.key}/price-feeds?status=Inactive`}
+                    invert
+                  >
+                    {publisher.inactiveFeeds}
+                  </Link>
+                ),
+                averageScore: (
                   <Score
                     width={PUBLISHER_SCORE_WIDTH}
-                    score={publisher.medianScore}
+                    score={publisher.averageScore}
                   />
                 ),
                 name: (

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

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

+ 14 - 349
apps/insights/src/components/Publisher/price-feeds-card.tsx

@@ -1,360 +1,25 @@
 "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";
-import {
-  type RowConfig,
-  type SortDescriptor,
-  Table,
-} from "@pythnetwork/component-library/Table";
-import { type ReactNode, Suspense, useMemo } from "react";
-import { useFilter, useCollator } from "react-aria";
+import { type ComponentProps, useCallback } from "react";
 
 import { useSelectPriceFeed } from "./price-feed-drawer-provider";
-import styles from "./price-feeds-card.module.scss";
-import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
-import { Cluster } from "../../services/pyth";
-import { Status as StatusType } from "../../status";
-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;
-  priceFeeds: PriceFeed[];
-};
-
-type PriceFeed = {
-  id: string;
-  score: number | undefined;
-  symbol: string;
-  displaySymbol: string;
-  uptimeScore: number | undefined;
-  deviationPenalty: number | undefined;
-  deviationScore: number | undefined;
-  stalledPenalty: number | undefined;
-  stalledScore: number | undefined;
-  icon: ReactNode;
-  cluster: Cluster;
-  status: StatusType;
-};
-
-export const PriceFeedsCard = ({ priceFeeds, ...props }: Props) => (
-  <Suspense fallback={<PriceFeedsCardContents isLoading {...props} />}>
-    <ResolvedPriceFeedsCard priceFeeds={priceFeeds} {...props} />
-  </Suspense>
-);
-
-const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => {
-  const collator = useCollator();
-  const filter = useFilter({ sensitivity: "base", usage: "search" });
+import { PriceComponentsCard } from "../PriceComponentsCard";
+
+export const PriceFeedsCard = (
+  props: Omit<
+    ComponentProps<typeof PriceComponentsCard>,
+    "onPriceComponentAction"
+  >,
+) => {
   const selectPriceFeed = useSelectPriceFeed();
-
-  const {
-    search,
-    sortDescriptor,
-    page,
-    pageSize,
-    updateSearch,
-    updateSortDescriptor,
-    updatePage,
-    updatePageSize,
-    paginatedItems,
-    numResults,
-    numPages,
-    mkPageLink,
-  } = useQueryParamFilterPagination(
-    priceFeeds,
-    (priceFeed, search) => filter.contains(priceFeed.displaySymbol, search),
-    (a, b, { column, direction }) => {
-      switch (column) {
-        case "score":
-        case "uptimeScore":
-        case "deviationScore":
-        case "stalledScore":
-        case "stalledPenalty":
-        case "deviationPenalty": {
-          if (a[column] === undefined && b[column] === undefined) {
-            return 0;
-          } else if (a[column] === undefined) {
-            return direction === "descending" ? 1 : -1;
-          } else if (b[column] === undefined) {
-            return direction === "descending" ? -1 : 1;
-          } else {
-            return (
-              (direction === "descending" ? -1 : 1) * (a[column] - b[column])
-            );
-          }
-        }
-
-        case "name": {
-          return (
-            (direction === "descending" ? -1 : 1) *
-            collator.compare(a.displaySymbol, b.displaySymbol)
-          );
-        }
-
-        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 0;
-        }
-      }
-    },
-    {
-      defaultPageSize: 20,
-      defaultSort: "name",
-      defaultDescending: false,
-    },
+  const onPriceComponentAction = useCallback(
+    ({ symbol }: { symbol: string }) => selectPriceFeed?.(symbol),
+    [selectPriceFeed],
   );
-
-  const rows = useMemo(
-    () =>
-      paginatedItems.map(
-        ({
-          id,
-          score,
-          uptimeScore,
-          deviationPenalty,
-          deviationScore,
-          stalledPenalty,
-          stalledScore,
-          displaySymbol,
-          symbol,
-          icon,
-          cluster,
-          status,
-        }) => ({
-          id,
-          data: {
-            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 !== undefined && (
-              <FormattedNumber
-                value={deviationPenalty}
-                maximumSignificantDigits={5}
-              />
-            ),
-            deviationScore: deviationScore !== undefined && (
-              <FormattedNumber
-                value={deviationScore}
-                maximumSignificantDigits={5}
-              />
-            ),
-            stalledPenalty: stalledPenalty !== undefined && (
-              <FormattedNumber
-                value={stalledPenalty}
-                maximumSignificantDigits={5}
-              />
-            ),
-            stalledScore: stalledScore !== undefined && (
-              <FormattedNumber
-                value={stalledScore}
-                maximumSignificantDigits={5}
-              />
-            ),
-            status: <StatusComponent status={status} />,
-          },
-          ...(selectPriceFeed && {
-            onAction: () => {
-              selectPriceFeed(symbol);
-            },
-          }),
-        }),
-      ),
-    [paginatedItems, selectPriceFeed],
-  );
-
   return (
-    <PriceFeedsCardContents
-      numResults={numResults}
-      search={search}
-      sortDescriptor={sortDescriptor}
-      numPages={numPages}
-      page={page}
-      pageSize={pageSize}
-      onSearchChange={updateSearch}
-      onSortChange={updateSortDescriptor}
-      onPageSizeChange={updatePageSize}
-      onPageChange={updatePage}
-      mkPageLink={mkPageLink}
-      rows={rows}
+    <PriceComponentsCard
+      onPriceComponentAction={onPriceComponentAction}
       {...props}
     />
   );
 };
-
-type PriceFeedsCardProps = Pick<Props, "className" | "toolbar"> &
-  (
-    | { isLoading: true }
-    | {
-        isLoading?: false;
-        numResults: number;
-        search: string;
-        sortDescriptor: SortDescriptor;
-        numPages: number;
-        page: number;
-        pageSize: number;
-        onSearchChange: (newSearch: string) => void;
-        onSortChange: (newSort: SortDescriptor) => void;
-        onPageSizeChange: (newPageSize: number) => void;
-        onPageChange: (newPage: number) => void;
-        mkPageLink: (page: number) => string;
-        rows: RowConfig<
-          | "score"
-          | "name"
-          | "uptimeScore"
-          | "deviationScore"
-          | "deviationPenalty"
-          | "stalledScore"
-          | "stalledPenalty"
-          | "status"
-        >[];
-      }
-  );
-
-const PriceFeedsCardContents = ({
-  className,
-  ...props
-}: PriceFeedsCardProps) => (
-  <Card
-    className={className}
-    title="Price Feeds"
-    toolbar={
-      <SearchInput
-        size="sm"
-        width={60}
-        placeholder="Feed symbol"
-        {...(props.isLoading
-          ? { isPending: true, isDisabled: true }
-          : {
-              value: props.search,
-              onChange: props.onSearchChange,
-            })}
-      />
-    }
-    {...(!props.isLoading && {
-      footer: (
-        <Paginator
-          numPages={props.numPages}
-          currentPage={props.page}
-          onPageChange={props.onPageChange}
-          pageSize={props.pageSize}
-          onPageSizeChange={props.onPageSizeChange}
-          pageSizeOptions={[10, 20, 30, 40, 50]}
-          mkPageLink={props.mkPageLink}
-        />
-      ),
-    })}
-  >
-    <Table
-      label="Price Feeds"
-      fill
-      rounded
-      stickyHeader={rootStyles.headerHeight}
-      columns={[
-        {
-          id: "score",
-          name: "SCORE",
-          alignment: "left",
-          width: SCORE_WIDTH,
-          loadingSkeleton: <Score isLoading width={SCORE_WIDTH} />,
-          allowsSorting: true,
-        },
-        {
-          id: "name",
-          name: "NAME / ID",
-          alignment: "left",
-          isRowHeader: true,
-          loadingSkeleton: <PriceFeedTag compact isLoading />,
-          fill: true,
-          allowsSorting: true,
-        },
-        {
-          id: "uptimeScore",
-          name: "UPTIME SCORE",
-          alignment: "center",
-          allowsSorting: true,
-        },
-        {
-          id: "deviationScore",
-          name: "DEVIATION SCORE",
-          alignment: "center",
-          allowsSorting: true,
-        },
-        {
-          id: "deviationPenalty",
-          name: "DEVIATION PENALTY",
-          alignment: "center",
-          allowsSorting: true,
-        },
-        {
-          id: "stalledScore",
-          name: "STALLED SCORE",
-          alignment: "center",
-          allowsSorting: true,
-        },
-        {
-          id: "stalledPenalty",
-          name: "STALLED PENALTY",
-          alignment: "center",
-          allowsSorting: true,
-        },
-        {
-          id: "status",
-          name: "STATUS",
-          alignment: "right",
-          allowsSorting: true,
-        },
-      ]}
-      {...(props.isLoading
-        ? { isLoading: true }
-        : {
-            rows: props.rows,
-            sortDescriptor: props.sortDescriptor,
-            onSortChange: props.onSortChange,
-            emptyState: (
-              <NoResults
-                query={props.search}
-                onClearSearch={() => {
-                  props.onSearchChange("");
-                }}
-              />
-            ),
-          })}
-    />
-  </Card>
-);

+ 18 - 5
apps/insights/src/components/Publisher/price-feeds.tsx

@@ -2,6 +2,7 @@ import { getPriceFeeds } from "./get-price-feeds";
 import { PriceFeedsCard } from "./price-feeds-card";
 import { Cluster, ClusterToName } from "../../services/pyth";
 import { PriceFeedIcon } from "../PriceFeedIcon";
+import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
   params: Promise<{
@@ -12,22 +13,34 @@ type Props = {
 export const PriceFeeds = async ({ params }: Props) => {
   const { key } = await params;
   const feeds = await getPriceFeeds(Cluster.Pythnet, key);
+  const metricsTime = feeds.find((feed) => feed.ranking !== undefined)?.ranking
+    ?.time;
 
   return (
     <PriceFeedsCard
-      priceFeeds={feeds.map(({ ranking, feed, status }) => ({
+      label="Price Feeds"
+      searchPlaceholder="Feed symbol"
+      metricsTime={metricsTime}
+      nameLoadingSkeleton={<PriceFeedTag compact isLoading />}
+      priceComponents={feeds.map(({ ranking, feed, status }) => ({
         id: `${feed.product.price_account}-${ClusterToName[Cluster.Pythnet]}`,
+        feedKey: feed.product.price_account,
         symbol: feed.symbol,
-        displaySymbol: feed.product.display_symbol,
         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,
+        publisherKey: key,
+        name: (
+          <PriceFeedTag
+            compact
+            symbol={feed.product.display_symbol}
+            icon={<PriceFeedIcon symbol={feed.product.display_symbol} />}
+          />
+        ),
+        nameAsString: feed.product.display_symbol,
       }))}
     />
   );

+ 28 - 14
apps/insights/src/components/Publishers/index.module.scss

@@ -4,11 +4,33 @@
 .publishers {
   @include theme.max-width;
 
-  .header {
-    @include theme.h3;
+  .headerContainer {
+    display: flex;
+    flex-flow: row nowrap;
+    align-items: center;
+    justify-content: space-between;
+
+    .header {
+      @include theme.h3;
+
+      color: theme.color("heading");
+      font-weight: theme.font-weight("semibold");
+    }
+
+    .rankingsLastUpdated {
+      @include theme.text("sm", "normal");
+
+      color: theme.color("muted");
+      display: flex;
+      flex-flow: row nowrap;
+      gap: theme.spacing(1);
+      align-items: center;
+      line-height: normal;
 
-    color: theme.color("heading");
-    font-weight: theme.font-weight("semibold");
+      .clockIcon {
+        font-size: theme.spacing(5);
+      }
+    }
   }
 
   .body {
@@ -23,7 +45,7 @@
       grid-template-columns: repeat(2, minmax(0, 1fr));
       gap: theme.spacing(4);
       align-items: center;
-      width: 40%;
+      width: 30%;
       position: sticky;
       top: root.$header-height;
 
@@ -67,15 +89,7 @@
     }
 
     .publishersCard {
-      width: 60%;
+      width: 70%;
     }
   }
 }
-
-.averageMedianScoreDescription {
-  margin: 0;
-
-  b {
-    font-weight: theme.font-weight("semibold");
-  }
-}

+ 32 - 56
apps/insights/src/components/Publishers/index.tsx

@@ -1,7 +1,5 @@
 import { ArrowSquareOut } from "@phosphor-icons/react/dist/ssr/ArrowSquareOut";
-import { Info } from "@phosphor-icons/react/dist/ssr/Info";
-import { Lightbulb } from "@phosphor-icons/react/dist/ssr/Lightbulb";
-import { Alert, AlertTrigger } from "@pythnetwork/component-library/Alert";
+import { ClockCountdown } from "@phosphor-icons/react/dist/ssr/ClockCountdown";
 import { Button } from "@pythnetwork/component-library/Button";
 import { Card } from "@pythnetwork/component-library/Card";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
@@ -11,30 +9,48 @@ import styles from "./index.module.scss";
 import { PublishersCard } from "./publishers-card";
 import { getPublishers } from "../../services/clickhouse";
 import { getPublisherCaps } from "../../services/hermes";
-import { Cluster, getData } from "../../services/pyth";
 import {
   getDelState,
   getClaimableRewards,
   getDistributedRewards,
 } from "../../services/staking";
+import { ExplainAverage } from "../Explanations";
+import { FormattedDate } from "../FormattedDate";
 import { FormattedTokens } from "../FormattedTokens";
 import { PublisherIcon } from "../PublisherIcon";
-import { PublisherTag } from "../PublisherTag";
 import { SemicircleMeter, Label } from "../SemicircleMeter";
 import { TokenIcon } from "../TokenIcon";
 
 const INITIAL_REWARD_POOL_SIZE = 60_000_000_000_000n;
 
 export const Publishers = async () => {
-  const [publishers, totalFeeds, oisStats] = await Promise.all([
+  const [publishers, oisStats] = await Promise.all([
     getPublishers(),
-    getTotalFeedCount(),
     getOisStats(),
   ]);
 
+  const rankingTime = publishers[0]?.timestamp;
+  const scoreTime = publishers[0]?.scoreTime;
+
   return (
     <div className={styles.publishers}>
-      <h1 className={styles.header}>Publishers</h1>
+      <div className={styles.headerContainer}>
+        <h1 className={styles.header}>Publishers</h1>
+        {rankingTime && (
+          <div className={styles.rankingsLastUpdated}>
+            <span>
+              Rankings last updated{" "}
+              <FormattedDate
+                value={rankingTime}
+                dateStyle="long"
+                timeStyle="long"
+                timeZone="utc"
+              />
+            </span>
+            <ClockCountdown className={styles.clockIcon} />
+          </div>
+        )}
+      </div>
       <div className={styles.body}>
         <section className={styles.stats}>
           <StatCard
@@ -43,46 +59,11 @@ export const Publishers = async () => {
             stat={publishers.length}
           />
           <StatCard
-            header="Avg. Median Score"
-            corner={
-              <AlertTrigger>
-                <Button
-                  variant="ghost"
-                  size="xs"
-                  beforeIcon={(props) => <Info weight="fill" {...props} />}
-                  rounded
-                  hideText
-                  className={styles.averageMedianScoreExplainButton ?? ""}
-                >
-                  Explain Average Median Score
-                </Button>
-                <Alert title="Average Median Score" icon={<Lightbulb />}>
-                  <p className={styles.averageMedianScoreDescription}>
-                    Each <b>Price Feed Component</b> that a <b>Publisher</b>{" "}
-                    provides has an associated <b>Score</b>, which is determined
-                    by that component{"'"}s <b>Uptime</b>,{" "}
-                    <b>Price Deviation</b>, and <b>Staleness</b>. The publisher
-                    {"'"}s <b>Median Score</b> measures the 50th percentile of
-                    the <b>Score</b> across all of that publisher{"'"}s{" "}
-                    <b>Price Feed Components</b>. The{" "}
-                    <b>Average Median Score</b> is the average of the{" "}
-                    <b>Median Scores</b> of all publishers who contribute to the
-                    Pyth Network.
-                  </p>
-                  <Button
-                    size="xs"
-                    variant="solid"
-                    href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking"
-                    target="_blank"
-                  >
-                    Learn more
-                  </Button>
-                </Alert>
-              </AlertTrigger>
-            }
+            header="Average Feed Score"
+            corner={<ExplainAverage scoreTime={scoreTime} />}
             stat={(
               publishers.reduce(
-                (sum, publisher) => sum + publisher.medianScore,
+                (sum, publisher) => sum + publisher.averageScore,
                 0,
               ) / publishers.length
             ).toFixed(2)}
@@ -150,16 +131,16 @@ export const Publishers = async () => {
         </section>
         <PublishersCard
           className={styles.publishersCard}
-          nameLoadingSkeleton={<PublisherTag isLoading />}
+          explainAverage={<ExplainAverage scoreTime={scoreTime} />}
           publishers={publishers.map(
-            ({ key, rank, numSymbols, medianScore }) => {
+            ({ key, rank, inactiveFeeds, activeFeeds, averageScore }) => {
               const knownPublisher = lookupPublisher(key);
               return {
                 id: key,
                 ranking: rank,
-                activeFeeds: numSymbols,
-                inactiveFeeds: totalFeeds - numSymbols,
-                medianScore: medianScore,
+                activeFeeds: activeFeeds,
+                inactiveFeeds: inactiveFeeds,
+                averageScore,
                 ...(knownPublisher && {
                   name: knownPublisher.name,
                   icon: <PublisherIcon knownPublisher={knownPublisher} />,
@@ -173,11 +154,6 @@ export const Publishers = async () => {
   );
 };
 
-const getTotalFeedCount = async () => {
-  const pythData = await getData(Cluster.Pythnet);
-  return pythData.filter(({ price }) => price.numComponentPrices > 0).length;
-};
-
 const getOisStats = async () => {
   const [delState, claimableRewards, distributedRewards, publisherCaps] =
     await Promise.all([

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

@@ -3,6 +3,7 @@
 import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Card } from "@pythnetwork/component-library/Card";
+import { Link } from "@pythnetwork/component-library/Link";
 import { Paginator } from "@pythnetwork/component-library/Paginator";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import {
@@ -14,18 +15,19 @@ import { type ReactNode, Suspense, useMemo } from "react";
 import { useFilter, useCollator } from "react-aria";
 
 import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
+import { ExplainActive, ExplainInactive } from "../Explanations";
 import { NoResults } from "../NoResults";
 import { PublisherTag } from "../PublisherTag";
 import { Ranking } from "../Ranking";
 import rootStyles from "../Root/index.module.scss";
 import { Score } from "../Score";
 
-const PUBLISHER_SCORE_WIDTH = 24;
+const PUBLISHER_SCORE_WIDTH = 38;
 
 type Props = {
   className?: string | undefined;
-  nameLoadingSkeleton: ReactNode;
   publishers: Publisher[];
+  explainAverage: ReactNode;
 };
 
 type Publisher = {
@@ -33,7 +35,7 @@ type Publisher = {
   ranking: number;
   activeFeeds: number;
   inactiveFeeds: number;
-  medianScore: number;
+  averageScore: number;
 } & (
   | { name: string; icon: ReactNode }
   | { name?: undefined; icon?: undefined }
@@ -71,7 +73,7 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
         case "ranking":
         case "activeFeeds":
         case "inactiveFeeds":
-        case "medianScore": {
+        case "averageScore": {
           return (
             (direction === "descending" ? -1 : 1) * (a[column] - b[column])
           );
@@ -100,7 +102,7 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
         ({
           id,
           ranking,
-          medianScore,
+          averageScore,
           activeFeeds,
           inactiveFeeds,
           ...publisher
@@ -118,10 +120,21 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
                 })}
               />
             ),
-            activeFeeds,
-            inactiveFeeds,
-            medianScore: (
-              <Score score={medianScore} width={PUBLISHER_SCORE_WIDTH} />
+            activeFeeds: (
+              <Link href={`/publishers/${id}/price-feeds?status=Active`} invert>
+                {activeFeeds}
+              </Link>
+            ),
+            inactiveFeeds: (
+              <Link
+                href={`/publishers/${id}/price-feeds?status=Inactive`}
+                invert
+              >
+                {inactiveFeeds}
+              </Link>
+            ),
+            averageScore: (
+              <Score score={averageScore} width={PUBLISHER_SCORE_WIDTH} />
             ),
           },
         }),
@@ -148,10 +161,7 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => {
   );
 };
 
-type PublishersCardContentsProps = Pick<
-  Props,
-  "className" | "nameLoadingSkeleton"
-> &
+type PublishersCardContentsProps = Pick<Props, "className" | "explainAverage"> &
   (
     | { isLoading: true }
     | {
@@ -168,14 +178,14 @@ type PublishersCardContentsProps = Pick<
         onPageChange: (newPage: number) => void;
         mkPageLink: (page: number) => string;
         rows: RowConfig<
-          "ranking" | "name" | "activeFeeds" | "inactiveFeeds" | "medianScore"
+          "ranking" | "name" | "activeFeeds" | "inactiveFeeds" | "averageScore"
         >[];
       }
   );
 
 const PublishersCardContents = ({
   className,
-  nameLoadingSkeleton,
+  explainAverage,
   ...props
 }: PublishersCardContentsProps) => (
   <Card
@@ -236,26 +246,41 @@ const PublishersCardContents = ({
           name: "NAME / ID",
           isRowHeader: true,
           alignment: "left",
-          loadingSkeleton: nameLoadingSkeleton,
+          loadingSkeleton: <PublisherTag isLoading />,
           allowsSorting: true,
         },
         {
           id: "activeFeeds",
-          name: "ACTIVE FEEDS",
+          name: (
+            <>
+              ACTIVE FEEDS
+              <ExplainActive />
+            </>
+          ),
           alignment: "center",
           width: 30,
           allowsSorting: true,
         },
         {
           id: "inactiveFeeds",
-          name: "INACTIVE FEEDS",
+          name: (
+            <>
+              INACTIVE FEEDS
+              <ExplainInactive />
+            </>
+          ),
           alignment: "center",
           width: 30,
           allowsSorting: true,
         },
         {
-          id: "medianScore",
-          name: "MEDIAN SCORE",
+          id: "averageScore",
+          name: (
+            <>
+              AVERAGE SCORE
+              {explainAverage}
+            </>
+          ),
           alignment: "right",
           width: PUBLISHER_SCORE_WIDTH,
           loadingSkeleton: <Score isLoading width={PUBLISHER_SCORE_WIDTH} />,

+ 5 - 5
apps/insights/src/components/Root/index.tsx

@@ -17,7 +17,7 @@ import {
 import { toHex } from "../../hex";
 import { LivePriceDataProvider } from "../../hooks/use-live-price-data";
 import { getPublishers } from "../../services/clickhouse";
-import { Cluster, getData } from "../../services/pyth";
+import { Cluster, getFeeds } from "../../services/pyth";
 import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PublisherIcon } from "../PublisherIcon";
 
@@ -26,8 +26,8 @@ type Props = {
 };
 
 export const Root = async ({ children }: Props) => {
-  const [data, publishers] = await Promise.all([
-    getData(Cluster.Pythnet),
+  const [feeds, publishers] = await Promise.all([
+    getFeeds(Cluster.Pythnet),
     getPublishers(),
   ]);
 
@@ -40,7 +40,7 @@ export const Root = async ({ children }: Props) => {
       className={styles.root}
     >
       <SearchDialogProvider
-        feeds={data.map((feed) => ({
+        feeds={feeds.map((feed) => ({
           id: feed.symbol,
           key: toHex(feed.product.price_account),
           displaySymbol: feed.product.display_symbol,
@@ -51,7 +51,7 @@ export const Root = async ({ children }: Props) => {
           const knownPublisher = lookupPublisher(publisher.key);
           return {
             id: publisher.key,
-            medianScore: publisher.medianScore,
+            averageScore: publisher.averageScore,
             ...(knownPublisher && {
               name: knownPublisher.name,
               icon: <PublisherIcon knownPublisher={knownPublisher} />,

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

@@ -51,7 +51,7 @@ type Props = {
   }[];
   publishers: ({
     id: string;
-    medianScore: number;
+    averageScore: number;
   } & (
     | { name: string; icon: ReactNode }
     | { name?: undefined; icon?: undefined }
@@ -69,10 +69,6 @@ export const SearchDialogProvider = ({
   const collator = useCollator();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
 
-  const updateSelectedType = useCallback((set: Set<ResultType | "">) => {
-    setType(set.values().next().value ?? "");
-  }, []);
-
   const close = useCallback(() => {
     searchDialogState.close();
     setTimeout(() => {
@@ -165,9 +161,9 @@ export const SearchDialogProvider = ({
               autoFocus
             />
             <SingleToggleGroup
-              selectedKeys={[type]}
+              selectedKey={type}
               // @ts-expect-error react-aria coerces everything to Key for some reason...
-              onSelectionChange={updateSelectedType}
+              onSelectionChange={setType}
               items={[
                 { id: "", children: "All" },
                 { id: ResultType.PriceFeed, children: "Price Feeds" },
@@ -257,7 +253,7 @@ export const SearchDialogProvider = ({
                           icon: result.icon,
                         })}
                       />
-                      <Score score={result.medianScore} />
+                      <Score score={result.averageScore} />
                     </>
                   )}
                 </ListBoxItem>

+ 1 - 0
apps/insights/src/components/Score/index.module.scss

@@ -3,6 +3,7 @@
 .meter {
   line-height: 0;
   width: calc(theme.spacing(1) * var(--width));
+  display: grid;
 
   .score {
     height: theme.spacing(6);

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

@@ -14,10 +14,10 @@ const getVariant = (status: StatusType) => {
       return "success";
     }
     case StatusType.Inactive: {
-      return "disabled";
+      return "error";
     }
     case StatusType.Unranked: {
-      return "error";
+      return "disabled";
     }
   }
 };

+ 96 - 29
apps/insights/src/services/clickhouse.ts

@@ -19,14 +19,54 @@ export const getPublishers = cache(
         z.strictObject({
           key: z.string(),
           rank: z.number(),
-          numSymbols: z.number(),
-          medianScore: z.number(),
+          activeFeeds: z
+            .string()
+            .transform((value) => Number.parseInt(value, 10)),
+          inactiveFeeds: z
+            .string()
+            .transform((value) => Number.parseInt(value, 10)),
+          averageScore: z.number(),
+          timestamp: z.string().transform((value) => new Date(`${value} UTC`)),
+          scoreTime: z.string().transform((value) => new Date(value)),
         }),
       ),
       {
         query: `
-          SELECT key, rank, numSymbols, medianScore
-          FROM insights_publishers(cluster={cluster: String})
+          WITH score_data AS (
+            SELECT
+              publisher,
+              time,
+              avg(final_score) AS averageScore,
+              countIf(uptime_score >= 0.5) AS activeFeeds,
+              countIf(uptime_score < 0.5) AS inactiveFeeds
+            FROM publisher_quality_ranking
+            WHERE cluster = {cluster:String}
+            AND time = (
+              SELECT max(time)
+              FROM publisher_quality_ranking
+              WHERE cluster = {cluster:String}
+              AND interval_days = 1
+            )
+            AND interval_days = 1
+            GROUP BY publisher, time
+          )
+          SELECT
+            timestamp,
+            publisher AS key,
+            rank,
+            activeFeeds,
+            inactiveFeeds,
+            score_data.averageScore,
+            score_data.time as scoreTime
+          FROM publishers_ranking
+          INNER JOIN score_data ON publishers_ranking.publisher = score_data.publisher
+          WHERE cluster = {cluster:String}
+          AND timestamp = (
+            SELECT max(timestamp)
+            FROM publishers_ranking
+            WHERE cluster = {cluster:String}
+          )
+          ORDER BY rank ASC, timestamp
         `,
         query_params: { cluster: "pythnet" },
       },
@@ -41,9 +81,25 @@ export const getRankingsByPublisher = cache(
   async (publisherKey: string) =>
     safeQuery(rankingsSchema, {
       query: `
-          SELECT * FROM insights__rankings
-          WHERE publisher = {publisherKey: String}
-        `,
+      SELECT
+          time,
+          symbol,
+          cluster,
+          publisher,
+          uptime_score,
+          deviation_score,
+          stalled_score,
+          final_score,
+          final_rank
+        FROM publisher_quality_ranking
+        WHERE time = (SELECT max(time) FROM publisher_quality_ranking)
+        AND publisher = {publisherKey: String}
+        AND interval_days = 1
+        ORDER BY
+          symbol ASC,
+          cluster ASC,
+          publisher ASC
+      `,
       query_params: { publisherKey },
     }),
   ["rankingsByPublisher"],
@@ -56,9 +112,25 @@ export const getRankingsBySymbol = cache(
   async (symbol: string) =>
     safeQuery(rankingsSchema, {
       query: `
-          SELECT * FROM insights__rankings
-          WHERE symbol = {symbol: String}
-        `,
+        SELECT
+          time,
+          symbol,
+          cluster,
+          publisher,
+          uptime_score,
+          deviation_score,
+          stalled_score,
+          final_score,
+          final_rank
+        FROM publisher_quality_ranking
+        WHERE time = (SELECT max(time) FROM publisher_quality_ranking)
+        AND symbol = {symbol: String}
+        AND interval_days = 1
+        ORDER BY
+          symbol ASC,
+          cluster ASC,
+          publisher ASC
+      `,
       query_params: { symbol },
     }),
   ["rankingsBySymbol"],
@@ -69,20 +141,15 @@ export const getRankingsBySymbol = cache(
 
 const rankingsSchema = z.array(
   z.strictObject({
+    time: z.string().transform((time) => new Date(time)),
     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),
   }),
 );
 
@@ -98,7 +165,13 @@ export const getYesterdaysPrices = cache(
       {
         query: `
           SELECT symbol, price
-          FROM insights_yesterdays_prices(symbols={symbols: Array(String)})
+          FROM prices
+          WHERE cluster = 'pythnet'
+          AND symbol IN {symbols:Array(String)}
+          AND time >= now() - toIntervalDay(1) - toIntervalMinute(10)
+          AND time <= now() - toIntervalDay(1)
+          ORDER BY time ASC
+          LIMIT 1 BY symbol
         `,
         query_params: { symbols },
       },
@@ -158,7 +231,7 @@ export const getFeedScoreHistory = cache(
               uptime_score AS uptimeScore,
               deviation_score AS deviationScore,
               stalled_score AS stalledScore
-            FROM default.publisher_quality_ranking
+            FROM publisher_quality_ranking
             WHERE publisher = {publisherKey: String}
             AND cluster = {cluster: String}
             AND symbol = {symbol: String}
@@ -216,16 +289,13 @@ export const getFeedPriceHistory = cache(
   },
 );
 
-export const getPublisherMedianScoreHistory = cache(
+export const getPublisherAverageScoreHistory = cache(
   async (key: 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(),
+          averageScore: z.number(),
         }),
       ),
       {
@@ -233,11 +303,8 @@ export const getPublisherMedianScoreHistory = cache(
           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
+              avg(final_score) AS averageScore
+            FROM publisher_quality_ranking
             WHERE publisher = {key: String}
             AND cluster = 'pythnet'
             GROUP BY time
@@ -249,7 +316,7 @@ export const getPublisherMedianScoreHistory = cache(
         query_params: { key },
       },
     ),
-  ["publisher-median-score-history"],
+  ["publisher-average-score-history"],
   {
     revalidate: ONE_HOUR_IN_SECONDS,
   },

+ 41 - 11
apps/insights/src/services/pyth.ts

@@ -55,7 +55,20 @@ const clients = {
   [Cluster.PythtestConformance]: mkClient(Cluster.PythtestConformance),
 } as const;
 
-export const getData = cache(
+export const getPublishersForFeed = cache(
+  async (cluster: Cluster, symbol: string) => {
+    const data = await clients[cluster].getData();
+    return data.productPrice
+      .get(symbol)
+      ?.priceComponents.map(({ publisher }) => publisher.toBase58());
+  },
+  ["publishers-for-feed"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);
+
+export const getFeeds = cache(
   async (cluster: Cluster) => {
     const data = await clients[cluster].getData();
     return priceFeedsSchema.parse(
@@ -77,6 +90,33 @@ export const getData = cache(
   },
 );
 
+export const getFeedsForPublisher = cache(
+  async (cluster: Cluster, publisher: string) => {
+    const data = await clients[cluster].getData();
+    return priceFeedsSchema.parse(
+      data.symbols
+        .filter(
+          (symbol) =>
+            data.productFromSymbol.get(symbol)?.display_symbol !== undefined,
+        )
+        .map((symbol) => ({
+          symbol,
+          product: data.productFromSymbol.get(symbol),
+          price: data.productPrice.get(symbol),
+        }))
+        .filter(({ price }) =>
+          price?.priceComponents.some(
+            (component) => component.publisher.toBase58() === publisher,
+          ),
+        ),
+    );
+  },
+  ["pyth-data"],
+  {
+    revalidate: ONE_HOUR_IN_SECONDS,
+  },
+);
+
 const priceFeedsSchema = z.array(
   z.object({
     symbol: z.string(),
@@ -104,20 +144,10 @@ 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 (cluster: Cluster) => {
-  const pythData = await getData(cluster);
-  return pythData.filter(({ price }) => price.numComponentPrices > 0).length;
-};
-
 export const getAssetPricesFromAccounts = (
   cluster: Cluster,
   ...args: Parameters<(typeof clients)[Cluster]["getAssetPricesFromAccounts"]>

+ 1 - 1
apps/insights/src/static-data/price-feeds.tsx

@@ -7,5 +7,5 @@ export const priceFeeds = {
     "Commodities.WTI1M",
     "Crypto.1INCH/USD",
   ],
-  featuredComingSoon: ["Rates.US2Y", "Crypto.ION/USD", "Equity.NL.BCOIN/USD"],
+  featuredComingSoon: ["Crypto.ION/USD", "Equity.NL.BCOIN/USD"],
 };

+ 27 - 2
apps/insights/src/status.ts

@@ -4,10 +4,35 @@ export enum Status {
   Active,
 }
 
-export const getStatus = (ranking?: { is_active: boolean }): Status => {
+export const getStatus = (ranking?: { uptime_score: number }): Status => {
   if (ranking) {
-    return ranking.is_active ? Status.Active : Status.Inactive;
+    return ranking.uptime_score >= 0.5 ? Status.Active : Status.Inactive;
   } else {
     return Status.Unranked;
   }
 };
+
+export const STATUS_NAMES = {
+  [Status.Active]: "Active",
+  [Status.Inactive]: "Inactive",
+  [Status.Unranked]: "Unranked",
+} as const;
+
+export type StatusName = (typeof STATUS_NAMES)[Status];
+
+export const statusNameToStatus = (name: string): Status | undefined => {
+  switch (name) {
+    case "Active": {
+      return Status.Active;
+    }
+    case "Inactive": {
+      return Status.Inactive;
+    }
+    case "Unranked": {
+      return Status.Unranked;
+    }
+    default: {
+      return undefined;
+    }
+  }
+};

+ 3 - 1
packages/component-library/src/Alert/index.tsx

@@ -17,6 +17,7 @@ export const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_S * 1000;
 type OwnProps = Pick<ComponentProps<typeof ModalDialog>, "children"> & {
   icon?: ReactNode | undefined;
   title: ReactNode;
+  bodyClassName?: string | undefined;
 };
 
 type Props = Omit<
@@ -30,6 +31,7 @@ export const Alert = ({
   title,
   children,
   className,
+  bodyClassName,
   ...props
 }: Props) => (
   <ModalDialog
@@ -65,7 +67,7 @@ export const Alert = ({
           {icon && <div className={styles.icon}>{icon}</div>}
           <div>{title}</div>
         </Heading>
-        <div className={styles.body}>
+        <div className={clsx(styles.body, bodyClassName)}>
           {typeof children === "function" ? children(...args) : children}
         </div>
       </>

+ 1 - 1
packages/component-library/src/Card/index.module.scss

@@ -60,7 +60,7 @@
       bottom: theme.spacing(0);
       display: flex;
       flex-flow: row nowrap;
-      gap: theme.spacing(2);
+      gap: theme.spacing(4);
       align-items: center;
     }
   }

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

@@ -12,7 +12,7 @@
     bottom: theme.spacing(4);
     right: theme.spacing(4);
     width: 60%;
-    max-width: theme.spacing(160);
+    max-width: theme.spacing(180);
     outline: none;
     background: theme.color("background", "primary");
     border: 1px solid theme.color("border");

+ 33 - 4
packages/component-library/src/SingleToggleGroup/index.tsx

@@ -2,28 +2,57 @@
 
 import clsx from "clsx";
 import { motion } from "motion/react";
-import { type ComponentProps, useId } from "react";
-import { ToggleButtonGroup, ToggleButton } from "react-aria-components";
+import { type ComponentProps, useId, useMemo } from "react";
+import {
+  type Key,
+  ToggleButtonGroup,
+  ToggleButton,
+} from "react-aria-components";
 
 import styles from "./index.module.scss";
 import buttonStyles from "../Button/index.module.scss";
 
 type OwnProps = {
+  selectedKey?: Key | undefined;
+  onSelectionChange?: (newValue: Key) => void;
   items: ComponentProps<typeof ToggleButton>[];
 };
 type Props = Omit<
   ComponentProps<typeof ToggleButtonGroup>,
-  keyof OwnProps | "selectionMode"
+  keyof OwnProps | "selectionMode" | "selectedKeys"
 > &
   OwnProps;
 
-export const SingleToggleGroup = ({ className, items, ...props }: Props) => {
+export const SingleToggleGroup = ({
+  selectedKey,
+  onSelectionChange,
+  className,
+  items,
+  ...props
+}: Props) => {
   const id = useId();
 
+  const handleSelectionChange = useMemo(
+    () =>
+      onSelectionChange
+        ? (set: Set<Key>) => {
+            const { value } = set.values().next();
+            if (value !== undefined) {
+              onSelectionChange(value);
+            }
+          }
+        : undefined,
+    [onSelectionChange],
+  );
+
   return (
     <ToggleButtonGroup
       className={clsx(styles.singleToggleGroup, className)}
       selectionMode="single"
+      {...(handleSelectionChange && {
+        onSelectionChange: handleSelectionChange,
+      })}
+      {...(selectedKey !== undefined && { selectedKeys: [selectedKey] })}
       {...props}
     >
       {items.map(({ className: tabClassName, children, ...toggleButton }) => (

+ 5 - 6
packages/component-library/src/StatCard/index.module.scss

@@ -27,17 +27,16 @@
       display: flex;
     }
 
-    .header,
-    .dualHeader {
+    .header {
       color: theme.color("muted");
+      text-align: left;
+      display: flex;
+      align-items: center;
+      gap: theme.spacing(2);
 
       @include theme.text("sm", "medium");
     }
 
-    .header {
-      text-align: left;
-    }
-
     .dualHeader {
       display: flex;
       flex-flow: row nowrap;

+ 4 - 4
packages/component-library/src/StatCard/index.tsx

@@ -75,10 +75,10 @@ export const StatCard = <T extends ElementType>({
             </>
           ) : (
             <>
-              <h2 className={styles.dualHeader}>
-                <span>{props.header1}</span>
-                <span>{props.header2}</span>
-              </h2>
+              <div className={styles.dualHeader}>
+                <h2 className={styles.header}>{props.header1}</h2>
+                <h2 className={styles.header}>{props.header2}</h2>
+              </div>
               <div className={styles.stats}>
                 <div className={styles.stat}>
                   <div className={styles.mainStat}>{props.stat1}</div>

+ 16 - 0
packages/component-library/src/Table/index.module.scss

@@ -86,6 +86,14 @@
         border-bottom: 1px solid theme.color("border");
         font-weight: theme.font-weight("medium");
 
+        .name {
+          display: flex;
+          flex-flow: row nowrap;
+          align-items: center;
+          gap: theme.spacing(2);
+          height: theme.spacing(4);
+        }
+
         .divider {
           width: 1px;
           height: theme.spacing(4);
@@ -135,6 +143,14 @@
         &[data-sort-direction="descending"] .sortButton .descending {
           opacity: 1;
         }
+
+        &[data-alignment="center"] .name {
+          justify-content: center;
+        }
+
+        &[data-alignment="right"] .name {
+          justify-content: flex-end;
+        }
       }
     }
 

+ 1 - 1
packages/component-library/src/Table/index.tsx

@@ -119,7 +119,7 @@ export const Table = <T extends string>({
             >
               {({ allowsSorting, sort, sortDirection }) => (
                 <>
-                  {column.name}
+                  <div className={styles.name}>{column.name}</div>
                   {allowsSorting && (
                     <Button
                       className={styles.sortButton ?? ""}