From 911e97258d1fed98db3a99462436b4714352219b Mon Sep 17 00:00:00 2001 From: Connor Prussin Date: Sun, 2 Feb 2025 00:01:28 -0800 Subject: [PATCH] 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 --- .../src/app/price-feeds/[slug]/layout.ts | 6 +- .../src/components/Explain/index.module.scss | 23 + .../insights/src/components/Explain/index.tsx | 35 + .../src/components/Explanations/index.tsx | 92 +++ .../src/components/NoResults/index.tsx | 2 +- .../components/PriceComponentDrawer/index.tsx | 14 +- .../index.module.scss} | 2 +- .../components/PriceComponentsCard/index.tsx | 600 ++++++++++++++++++ .../src/components/PriceFeed/chart-page.tsx | 8 +- .../src/components/PriceFeed/layout.tsx | 10 +- .../components/PriceFeed/publishers-card.tsx | 473 ++------------ .../src/components/PriceFeed/publishers.tsx | 96 +-- .../src/components/PriceFeeds/index.tsx | 8 +- .../Publisher/active-feeds-card.tsx | 53 -- .../components/Publisher/get-price-feeds.tsx | 46 +- .../components/Publisher/layout.module.scss | 29 +- .../src/components/Publisher/layout.tsx | 211 +++--- .../src/components/Publisher/performance.tsx | 56 +- .../Publisher/price-feeds-card.module.scss | 8 - .../components/Publisher/price-feeds-card.tsx | 363 +---------- .../src/components/Publisher/price-feeds.tsx | 23 +- .../components/Publishers/index.module.scss | 42 +- .../src/components/Publishers/index.tsx | 88 +-- .../components/Publishers/publishers-card.tsx | 65 +- apps/insights/src/components/Root/index.tsx | 10 +- .../src/components/Root/search-dialog.tsx | 12 +- .../src/components/Score/index.module.scss | 1 + apps/insights/src/components/Status/index.tsx | 4 +- apps/insights/src/services/clickhouse.ts | 125 +++- apps/insights/src/services/pyth.ts | 52 +- apps/insights/src/static-data/price-feeds.tsx | 2 +- apps/insights/src/status.ts | 29 +- .../component-library/src/Alert/index.tsx | 4 +- .../src/Card/index.module.scss | 2 +- .../src/Drawer/index.module.scss | 2 +- .../src/SingleToggleGroup/index.tsx | 37 +- .../src/StatCard/index.module.scss | 11 +- .../component-library/src/StatCard/index.tsx | 8 +- .../src/Table/index.module.scss | 16 + .../component-library/src/Table/index.tsx | 2 +- 40 files changed, 1455 insertions(+), 1215 deletions(-) create mode 100644 apps/insights/src/components/Explain/index.module.scss create mode 100644 apps/insights/src/components/Explain/index.tsx create mode 100644 apps/insights/src/components/Explanations/index.tsx rename apps/insights/src/components/{PriceFeed/publishers-card.module.scss => PriceComponentsCard/index.module.scss} (89%) create mode 100644 apps/insights/src/components/PriceComponentsCard/index.tsx delete mode 100644 apps/insights/src/components/Publisher/active-feeds-card.tsx delete mode 100644 apps/insights/src/components/Publisher/price-feeds-card.module.scss diff --git a/apps/insights/src/app/price-feeds/[slug]/layout.ts b/apps/insights/src/app/price-feeds/[slug]/layout.ts index ab9d1c559c..c7f9934ea9 100644 --- a/apps/insights/src/app/price-feeds/[slug]/layout.ts +++ b/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) })); }; diff --git a/apps/insights/src/components/Explain/index.module.scss b/apps/insights/src/components/Explain/index.module.scss new file mode 100644 index 0000000000..4f5e356312 --- /dev/null +++ b/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; + } +} diff --git a/apps/insights/src/components/Explain/index.tsx b/apps/insights/src/components/Explain/index.tsx new file mode 100644 index 0000000000..a3b2badb88 --- /dev/null +++ b/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["size"]; + title: string; + children: ReactNode; +}; + +export const Explain = ({ size, title, children }: Props) => ( + + + } + bodyClassName={styles.description} + > + {children} + + +); diff --git a/apps/insights/src/components/Explanations/index.tsx b/apps/insights/src/components/Explanations/index.tsx new file mode 100644 index 0000000000..787bfee5e0 --- /dev/null +++ b/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 ( + +

+ Each Price Feed Component that a Publisher provides has an + associated Score, which is determined by that component{"'"}s{" "} + Uptime, Price Deviation, and Staleness. The{" "} + Average Feed Score is the average of the scores for all{" "} + Price Feed Components. +

+ {scoreTime && } + +
+ ); +}; + +export const EvaluationTime = ({ scoreTime }: { scoreTime: Date }) => { + const startTime = useMemo(() => { + const date = new Date(scoreTime); + date.setDate(date.getDate() - 1); + return date; + }, [scoreTime]); + + return ( +

+ This value is calculated based on feed performance from{" "} + + + {" "} + to{" "} + + + + . +

+ ); +}; + +export const ExplainActive = () => ( + +

+ 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. +

+ +
+); + +export const ExplainInactive = () => ( + +

+ 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. +

+ +
+); + +const NeitherActiveNorInactiveNote = () => ( +

+ Note that a publisher{"'"}s feed may not be considered either active or + inactive if Pyth has not yet calculated quality rankings for it. +

+); diff --git a/apps/insights/src/components/NoResults/index.tsx b/apps/insights/src/components/NoResults/index.tsx index 0317574a5a..0ac8c3622d 100644 --- a/apps/insights/src/components/NoResults/index.tsx +++ b/apps/insights/src/components/NoResults/index.tsx @@ -33,7 +33,7 @@ export const NoResults = ({ onClearSearch, ...props }: Props) => (

{"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}"`}.`}

{onClearSearch && ( diff --git a/apps/insights/src/components/PriceComponentDrawer/index.tsx b/apps/insights/src/components/PriceComponentDrawer/index.tsx index 434f2f9f18..aa7862043a 100644 --- a/apps/insights/src/components/PriceComponentDrawer/index.tsx +++ b/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} - + + + } isOpen={isFeedDrawerOpen} diff --git a/apps/insights/src/components/PriceFeed/publishers-card.module.scss b/apps/insights/src/components/PriceComponentsCard/index.module.scss similarity index 89% rename from apps/insights/src/components/PriceFeed/publishers-card.module.scss rename to apps/insights/src/components/PriceComponentsCard/index.module.scss index 1c329ac5e9..9c0965c61a 100644 --- a/apps/insights/src/components/PriceFeed/publishers-card.module.scss +++ b/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; diff --git a/apps/insights/src/components/PriceComponentsCard/index.tsx b/apps/insights/src/components/PriceComponentsCard/index.tsx new file mode 100644 index 0000000000..ccddaae564 --- /dev/null +++ b/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) => ( + }> + + +); + +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: ( +
+ {component.name} + {component.cluster === Cluster.PythtestConformance && ( + + test + + )} +
+ ), + ...(showQuality + ? { + score: component.score !== undefined && ( + + ), + uptimeScore: component.uptimeScore !== undefined && ( + + ), + deviationScore: component.deviationScore !== undefined && ( + + ), + stalledScore: component.stalledScore !== undefined && ( + + ), + } + : { + slot: ( + + ), + price: ( + + ), + confidence: ( + + ), + }), + 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 ( + + ); +}; + +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[]; + } + ); + +export const PriceComponentsCardContents = ({ + className, + metricsTime, + nameLoadingSkeleton, + label, + searchPlaceholder, + ...props +}: PriceComponentsCardProps) => { + const collator = useCollator(); + return ( + + {label} + {!props.isLoading && ( + + {props.numResults} + + )} + + } + toolbar={ + <> + + 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, + })} + /> + + { + props.setShowQuality(newValue === "quality"); + }, + })} + items={[ + { id: "prices", children: "Prices" }, + { id: "quality", children: "Quality" }, + ]} + /> + + } + {...(!props.isLoading && { + footer: ( + + ), + })} + > + + STATUS + + A publisher{"'"}s feed have one of the following statuses: +
    +
  • + Active feeds have better than 50% uptime over the + last day +
  • +
  • + Inactive feeds have worse than 50% uptime over the + last day +
  • +
  • + Unranked feeds have not yet been evaluated by Pyth +
  • +
+ {metricsTime && } +
+ + ), + alignment: "right", + allowsSorting: true, + }, + ]} + {...(props.isLoading + ? { isLoading: true } + : { + rows: props.rows, + sortDescriptor: props.sortDescriptor, + onSortChange: props.onSortChange, + emptyState: ( + { + props.onSearchChange(""); + props.onStatusChange(""); + }} + /> + ), + })} + /> + + ); +}; + +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 + +

+ Uptime is the percentage of time that a publisher{"'"}s feed + is available and active. +

+ {metricsTime && } + +
+ + ), + alignment: "center" as const, + allowsSorting: true, + }, + { + id: "deviationScore", + width: 20, + name: ( + <> + DEVIATION SCORE + +

+ Deviation measures how close a publisher{"'"}s price is to + what Pyth believes to be the true market price. +

+ {metricsTime && } + +
+ + ), + alignment: "center" as const, + allowsSorting: true, + }, + { + id: "stalledScore", + width: 20, + name: ( + <> + STALLED SCORE + +

+ 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. +

+ {metricsTime && } + +
+ + ), + alignment: "center" as const, + allowsSorting: true, + }, + { + id: "score", + name: ( + <> + FINAL SCORE + + The final score is calculated by combining the three score + components as follows: +
    +
  • + Uptime Score (40% weight) +
  • +
  • + Deviation Score (40% weight) +
  • +
  • + Stalled Score (20% weight) +
  • +
+ {metricsTime && } + +
+ + ), + alignment: "left" as const, + width: SCORE_WIDTH, + loadingSkeleton: , + 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, + }, + ]; + } +}; diff --git a/apps/insights/src/components/PriceFeed/chart-page.tsx b/apps/insights/src/components/PriceFeed/chart-page.tsx index b199352531..3eed55b172 100644 --- a/apps/insights/src/components/PriceFeed/chart-page.tsx +++ b/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 ? ( diff --git a/apps/insights/src/components/PriceFeed/layout.tsx b/apps/insights/src/components/PriceFeed/layout.tsx index 08629dcfea..53a1b291bf 100644 --- a/apps/insights/src/components/PriceFeed/layout.tsx +++ b/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 ? (
@@ -65,7 +65,7 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
feed.symbol !== symbol) .map((feed) => ({ id: feed.symbol, diff --git a/apps/insights/src/components/PriceFeed/publishers-card.tsx b/apps/insights/src/components/PriceFeed/publishers-card.tsx index da94171f09..c7412c7007 100644 --- a/apps/insights/src/components/PriceFeed/publishers-card.tsx +++ b/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, + "onPriceComponentAction" +>; -export const PublishersCard = ({ publishers, ...props }: Props) => ( - }> - +export const PublishersCard = ({ priceComponents, ...props }: Props) => ( + }> + ); -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 && ( - - ), - name: ( -
- - {cluster === Cluster.PythtestConformance && ( - - test - - )} -
- ), - uptimeScore: uptimeScore && ( - - ), - deviationPenalty: deviationPenalty && ( - - ), - deviationScore: deviationScore && ( - - ), - stalledPenalty: stalledPenalty && ( - - ), - stalledScore: stalledScore && ( - - ), - 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], + // ); + // + // Show test feeds + // return ( <> - {selectedPublisher && ( } + title={selectedPublisher.name} navigateButtonText="Open Publisher" navigateHref={`/publishers/${selectedPublisher.publisherKey}`} /> @@ -281,162 +102,6 @@ const ResolvedPublishersCard = ({ ); }; -type PublishersCardProps = Pick & - ( - | { 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) => ( - - - Show test feeds - - - - } - {...(!props.isLoading && { - footer: ( - - ), - })} - > -
, - allowsSorting: true, - }, - { - id: "name", - name: "NAME / ID", - alignment: "left", - isRowHeader: true, - loadingSkeleton: , - 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: ( - { - props.onSearchChange(""); - }} - /> - ), - })} - /> - -); - const usePublisherDrawer = (publishers: Publisher[]) => { const logger = useLogger(); const [selectedPublisherKey, setSelectedPublisher] = useQueryState( diff --git a/apps/insights/src/components/PriceFeed/publishers.tsx b/apps/insights/src/components/PriceFeed/publishers.tsx index 0e8713bdc5..5bdc4a625f 100644 --- a/apps/insights/src/components/PriceFeed/publishers.tsx +++ b/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() + ) : ( } + 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: ( + , + })} + /> + ), + 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: , - }), + cluster, + knownPublisher: lookupPublisher(publisher), }; - }); + }) ?? [] + ); }; diff --git a/apps/insights/src/components/PriceFeeds/index.tsx b/apps/insights/src/components/PriceFeeds/index.tsx index 9af81d165f..c78d8a7ba1 100644 --- a/apps/insights/src/components/PriceFeeds/index.tsx +++ b/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 () => { } + corner={} /> @@ -200,7 +200,7 @@ const FeaturedFeedsCard = ({ ); 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 }; diff --git a/apps/insights/src/components/Publisher/active-feeds-card.tsx b/apps/insights/src/components/Publisher/active-feeds-card.tsx deleted file mode 100644 index 42b3a9c4ca..0000000000 --- a/apps/insights/src/components/Publisher/active-feeds-card.tsx +++ /dev/null @@ -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 ( - - - % - - } - miniStat2={ - <> - - % - - } - {...(layoutSegment !== "price-feeds" && { - href: `/publishers/${publisherKey}/price-feeds`, - })} - > - - - ); -}; diff --git a/apps/insights/src/components/Publisher/get-price-feeds.tsx b/apps/insights/src/components/Publisher/get-price-feeds.tsx index cb51e12336..397396a06b 100644 --- a/apps/insights/src/components/Publisher/get-price-feeds.tsx +++ b/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), + }; + }); }; diff --git a/apps/insights/src/components/Publisher/layout.module.scss b/apps/insights/src/components/Publisher/layout.module.scss index b858dc42e1..36978a00a9 100644 --- a/apps/insights/src/components/Publisher/layout.module.scss +++ b/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); - } -} diff --git a/apps/insights/src/components/Publisher/layout.tsx b/apps/insights/src/components/Publisher/layout.tsx index ecd33b8a7d..53e14cadb9 100644 --- a/apps/insights/src/components/Publisher/layout.tsx +++ b/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 ? ( ({ @@ -115,25 +118,13 @@ export const PublishersLayout = async ({ children, params }: Props) => { variant="primary" header="Publisher Ranking" corner={ - - - }> -

- Each Publisher receives a Ranking which is - derived from the number of price feeds the{" "} - Publisher is actively publishing. -

-
-
+ +

+ Each Publisher receives a Ranking which is + derived from the number of price feeds the Publisher{" "} + is actively publishing. +

+
} data={rankingHistory.map(({ timestamp, rank }) => ({ x: timestamp, @@ -158,76 +149,94 @@ export const PublishersLayout = async ({ children, params }: Props) => { ), })} /> - - } - data={medianScoreHistory.map(({ time, score }) => ({ - x: time, - y: score, - displayX: ( - - - - ), - displayY: ( - - ), - }))} - stat={ + } + data={averageScoreHistory.map(({ time, averageScore }) => ({ + x: time, + y: averageScore, + displayX: ( + + + + ), + displayY: ( - } - {...(previousMedianScore && { - miniStat: ( - - ), - })} - /> - - Documentation - - } - > - - } 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. - - - - feed.status === Status.Active) - .length + ), + }))} + stat={ + } - totalFeeds={totalFeedsCount} + {...(previousAverageScore && { + miniStat: ( + + ), + })} /> + + Active Feeds + + + } + header2={ + <> + + Inactive Feeds + + } + stat1={ + + {publisher.activeFeeds} + + } + stat2={ + + {publisher.inactiveFeeds} + + } + miniStat1={ + <> + + % + + } + miniStat2={ + <> + + % + + } + > + + { % } - corner={} + corner={} > { 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 + + + ), alignment: "center", width: 30, }, { id: "inactiveFeeds", - name: "INACTIVE FEEDS", + name: ( + <> + INACTIVE FEEDS + + + ), alignment: "center", width: 30, }, { - id: "medianScore", - name: "MEDIAN SCORE", + id: "averageScore", + name: ( + <> + AVERAGE SCORE + + + ), alignment: "right", width: PUBLISHER_SCORE_WIDTH, }, @@ -93,12 +113,26 @@ export const Performance = async ({ params }: Props) => { {publisher.rank} ), - activeFeeds: publisher.numSymbols, - inactiveFeeds: totalFeeds - publisher.numSymbols, - medianScore: ( + activeFeeds: ( + + {publisher.activeFeeds} + + ), + inactiveFeeds: ( + + {publisher.inactiveFeeds} + + ), + averageScore: ( ), name: ( diff --git a/apps/insights/src/components/Publisher/price-feeds-card.module.scss b/apps/insights/src/components/Publisher/price-feeds-card.module.scss deleted file mode 100644 index 13465839ba..0000000000 --- a/apps/insights/src/components/Publisher/price-feeds-card.module.scss +++ /dev/null @@ -1,8 +0,0 @@ -@use "@pythnetwork/component-library/theme"; - -.priceFeedName { - display: flex; - flex-flow: row nowrap; - align-items: center; - gap: theme.spacing(6); -} diff --git a/apps/insights/src/components/Publisher/price-feeds-card.tsx b/apps/insights/src/components/Publisher/price-feeds-card.tsx index 6e1a7ab171..ce13879f19 100644 --- a/apps/insights/src/components/Publisher/price-feeds-card.tsx +++ b/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) => ( - }> - - -); - -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, + "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: ( -
- - {cluster === Cluster.PythtestConformance && ( - - test - - )} -
- ), - score: score !== undefined && ( - - ), - uptimeScore: uptimeScore !== undefined && ( - - ), - deviationPenalty: deviationPenalty !== undefined && ( - - ), - deviationScore: deviationScore !== undefined && ( - - ), - stalledPenalty: stalledPenalty !== undefined && ( - - ), - stalledScore: stalledScore !== undefined && ( - - ), - status: , - }, - ...(selectPriceFeed && { - onAction: () => { - selectPriceFeed(symbol); - }, - }), - }), - ), - [paginatedItems, selectPriceFeed], - ); - return ( - ); }; - -type PriceFeedsCardProps = Pick & - ( - | { 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) => ( - - } - {...(!props.isLoading && { - footer: ( - - ), - })} - > -
, - allowsSorting: true, - }, - { - id: "name", - name: "NAME / ID", - alignment: "left", - isRowHeader: true, - loadingSkeleton: , - 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: ( - { - props.onSearchChange(""); - }} - /> - ), - })} - /> - -); diff --git a/apps/insights/src/components/Publisher/price-feeds.tsx b/apps/insights/src/components/Publisher/price-feeds.tsx index 313e8f9fc8..82cfcf305f 100644 --- a/apps/insights/src/components/Publisher/price-feeds.tsx +++ b/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 ( ({ + label="Price Feeds" + searchPlaceholder="Feed symbol" + metricsTime={metricsTime} + nameLoadingSkeleton={} + 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: , 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: ( + } + /> + ), + nameAsString: feed.product.display_symbol, }))} /> ); diff --git a/apps/insights/src/components/Publishers/index.module.scss b/apps/insights/src/components/Publishers/index.module.scss index a7568592a6..e5549a60de 100644 --- a/apps/insights/src/components/Publishers/index.module.scss +++ b/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"); - } -} diff --git a/apps/insights/src/components/Publishers/index.tsx b/apps/insights/src/components/Publishers/index.tsx index 1d53cc051c..9446cf84b5 100644 --- a/apps/insights/src/components/Publishers/index.tsx +++ b/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 (
-

Publishers

+
+

Publishers

+ {rankingTime && ( +
+ + Rankings last updated{" "} + + + +
+ )} +
{ stat={publishers.length} /> - - }> -

- Each Price Feed Component that a Publisher{" "} - provides has an associated Score, which is determined - by that component{"'"}s Uptime,{" "} - Price Deviation, and Staleness. The publisher - {"'"}s Median Score measures the 50th percentile of - the Score across all of that publisher{"'"}s{" "} - Price Feed Components. The{" "} - Average Median Score is the average of the{" "} - Median Scores of all publishers who contribute to the - Pyth Network. -

- -
- - } + header="Average Feed Score" + corner={} 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 () => {
} + explainAverage={} 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: , @@ -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([ diff --git a/apps/insights/src/components/Publishers/publishers-card.tsx b/apps/insights/src/components/Publishers/publishers-card.tsx index 0a3ee7a752..db7c03d9a0 100644 --- a/apps/insights/src/components/Publishers/publishers-card.tsx +++ b/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: ( - + activeFeeds: ( + + {activeFeeds} + + ), + inactiveFeeds: ( + + {inactiveFeeds} + + ), + averageScore: ( + ), }, }), @@ -148,10 +161,7 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => { ); }; -type PublishersCardContentsProps = Pick< - Props, - "className" | "nameLoadingSkeleton" -> & +type PublishersCardContentsProps = Pick & ( | { 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) => ( , allowsSorting: true, }, { id: "activeFeeds", - name: "ACTIVE FEEDS", + name: ( + <> + ACTIVE FEEDS + + + ), alignment: "center", width: 30, allowsSorting: true, }, { id: "inactiveFeeds", - name: "INACTIVE FEEDS", + name: ( + <> + INACTIVE FEEDS + + + ), alignment: "center", width: 30, allowsSorting: true, }, { - id: "medianScore", - name: "MEDIAN SCORE", + id: "averageScore", + name: ( + <> + AVERAGE SCORE + {explainAverage} + + ), alignment: "right", width: PUBLISHER_SCORE_WIDTH, loadingSkeleton: , diff --git a/apps/insights/src/components/Root/index.tsx b/apps/insights/src/components/Root/index.tsx index 14bb5e4b54..73913c0815 100644 --- a/apps/insights/src/components/Root/index.tsx +++ b/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} > ({ + 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: , diff --git a/apps/insights/src/components/Root/search-dialog.tsx b/apps/insights/src/components/Root/search-dialog.tsx index 29393761a6..5ecdb17984 100644 --- a/apps/insights/src/components/Root/search-dialog.tsx +++ b/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) => { - setType(set.values().next().value ?? ""); - }, []); - const close = useCallback(() => { searchDialogState.close(); setTimeout(() => { @@ -165,9 +161,9 @@ export const SearchDialogProvider = ({ autoFocus /> - + )} diff --git a/apps/insights/src/components/Score/index.module.scss b/apps/insights/src/components/Score/index.module.scss index 9500e55b7e..e09276ccd2 100644 --- a/apps/insights/src/components/Score/index.module.scss +++ b/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); diff --git a/apps/insights/src/components/Status/index.tsx b/apps/insights/src/components/Status/index.tsx index 728a144956..921cd3b966 100644 --- a/apps/insights/src/components/Status/index.tsx +++ b/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"; } } }; diff --git a/apps/insights/src/services/clickhouse.ts b/apps/insights/src/services/clickhouse.ts index 532ee74594..f6d205d745 100644 --- a/apps/insights/src/services/clickhouse.ts +++ b/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, }, diff --git a/apps/insights/src/services/pyth.ts b/apps/insights/src/services/pyth.ts index 47815000c9..b57b3c6e82 100644 --- a/apps/insights/src/services/pyth.ts +++ b/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"]> diff --git a/apps/insights/src/static-data/price-feeds.tsx b/apps/insights/src/static-data/price-feeds.tsx index 37b0045d8f..84201a859a 100644 --- a/apps/insights/src/static-data/price-feeds.tsx +++ b/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"], }; diff --git a/apps/insights/src/status.ts b/apps/insights/src/status.ts index e878ec8115..96940a5fc2 100644 --- a/apps/insights/src/status.ts +++ b/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; + } + } +}; diff --git a/packages/component-library/src/Alert/index.tsx b/packages/component-library/src/Alert/index.tsx index 5126513a2a..6e34bc396b 100644 --- a/packages/component-library/src/Alert/index.tsx +++ b/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, "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) => ( {icon}
}
{title}
-
+
{typeof children === "function" ? children(...args) : children}
diff --git a/packages/component-library/src/Card/index.module.scss b/packages/component-library/src/Card/index.module.scss index 2f127ce2a6..646f50e241 100644 --- a/packages/component-library/src/Card/index.module.scss +++ b/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; } } diff --git a/packages/component-library/src/Drawer/index.module.scss b/packages/component-library/src/Drawer/index.module.scss index 3840e4b28e..c6a32e96d2 100644 --- a/packages/component-library/src/Drawer/index.module.scss +++ b/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"); diff --git a/packages/component-library/src/SingleToggleGroup/index.tsx b/packages/component-library/src/SingleToggleGroup/index.tsx index a445f37c10..4c4625b721 100644 --- a/packages/component-library/src/SingleToggleGroup/index.tsx +++ b/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[]; }; type Props = Omit< ComponentProps, - 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) => { + const { value } = set.values().next(); + if (value !== undefined) { + onSelectionChange(value); + } + } + : undefined, + [onSelectionChange], + ); + return ( {items.map(({ className: tabClassName, children, ...toggleButton }) => ( diff --git a/packages/component-library/src/StatCard/index.module.scss b/packages/component-library/src/StatCard/index.module.scss index 5ea1ee3f72..e0a3379064 100644 --- a/packages/component-library/src/StatCard/index.module.scss +++ b/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; diff --git a/packages/component-library/src/StatCard/index.tsx b/packages/component-library/src/StatCard/index.tsx index f45038a5c5..2feea5b25d 100644 --- a/packages/component-library/src/StatCard/index.tsx +++ b/packages/component-library/src/StatCard/index.tsx @@ -75,10 +75,10 @@ export const StatCard = ({ ) : ( <> -

- {props.header1} - {props.header2} -

+
+

{props.header1}

+

{props.header2}

+
{props.stat1}
diff --git a/packages/component-library/src/Table/index.module.scss b/packages/component-library/src/Table/index.module.scss index 6e96da9c1f..09e95b66b9 100644 --- a/packages/component-library/src/Table/index.module.scss +++ b/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; + } } } diff --git a/packages/component-library/src/Table/index.tsx b/packages/component-library/src/Table/index.tsx index 669cacd692..96b65804ab 100644 --- a/packages/component-library/src/Table/index.tsx +++ b/packages/component-library/src/Table/index.tsx @@ -119,7 +119,7 @@ export const Table = ({ > {({ allowsSorting, sort, sortDirection }) => ( <> - {column.name} +
{column.name}
{allowsSorting && (