diff --git a/apps/insights/src/app/api/pyth/get-feeds-for-publisher/[publisher]/route.ts b/apps/insights/src/app/api/pyth/get-feeds-for-publisher/[publisher]/route.ts index a15c0c9b16..bb49c024aa 100644 --- a/apps/insights/src/app/api/pyth/get-feeds-for-publisher/[publisher]/route.ts +++ b/apps/insights/src/app/api/pyth/get-feeds-for-publisher/[publisher]/route.ts @@ -38,7 +38,6 @@ export const GET = async ( const filteredFeeds = feeds.filter((feed) => feed.price.priceComponents.some((c) => c.publisher === publisher), ); - return new Response(stringify(filteredFeeds), { headers: { "Content-Type": "application/json", diff --git a/apps/insights/src/components/Explanations/index.tsx b/apps/insights/src/components/Explanations/index.tsx index a23e239b64..aa093571a4 100644 --- a/apps/insights/src/components/Explanations/index.tsx +++ b/apps/insights/src/components/Explanations/index.tsx @@ -24,19 +24,25 @@ export const ExplainPermissioned = ({ scoreTime, }: { scoreTime?: Date | undefined; -}) => { - return ( - -

- This is the number of Price Feeds that a Publisher has - permissions to publish to. The publisher is not necessarily pushing data - for all the feeds they have access to, and some feeds may not be live - yet. -

- {scoreTime && } -
- ); -}; +}) => ( + +

+ This is the number of Price Feeds that a Publisher has + permissions to publish to. The publisher is not necessarily pushing data + for all the feeds they have access to, and some feeds may not be live yet. +

+ {scoreTime && } +
+); + +export const ExplainUnpermissioned = () => ( + +

+ This is the number of Price Feeds that a Publisher does not + have permissions to publish to. +

+
+); export const ExplainAverage = ({ scoreTime, @@ -96,31 +102,3 @@ export const EvaluationTime = ({ scoreTime }: { scoreTime: Date }) => {

); }; - -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/FormattedNumber/index.tsx b/apps/insights/src/components/FormattedNumber/index.tsx index 3fadfd2950..c5e2bce5fc 100644 --- a/apps/insights/src/components/FormattedNumber/index.tsx +++ b/apps/insights/src/components/FormattedNumber/index.tsx @@ -4,7 +4,7 @@ import { useMemo } from "react"; import { useNumberFormatter } from "react-aria"; type Props = Parameters[0] & { - value: number; + value: number | bigint; }; export const FormattedNumber = ({ value, ...args }: Props) => { diff --git a/apps/insights/src/components/LivePrices/index.tsx b/apps/insights/src/components/LivePrices/index.tsx index 0bf30b37e7..fc79fe7889 100644 --- a/apps/insights/src/components/LivePrices/index.tsx +++ b/apps/insights/src/components/LivePrices/index.tsx @@ -14,6 +14,7 @@ import { } from "../../hooks/use-live-price-data"; import { usePriceFormatter } from "../../hooks/use-price-formatter"; import type { Cluster } from "../../services/pyth"; +import { useLivePublishersData } from '../../hooks/use-live-publishers-data'; export const SKELETON_WIDTH = 20; @@ -210,7 +211,6 @@ export const LiveComponentValue = ({ cluster, }: LiveComponentValueProps) => { const { current } = useLivePriceComponent(cluster, feedKey, publisherKey); - return current !== undefined || defaultValue !== undefined ? ( (current?.latest[field].toString() ?? defaultValue) ) : ( diff --git a/apps/insights/src/components/PriceComponentDrawer/index.tsx b/apps/insights/src/components/PriceComponentDrawer/index.tsx index 49540eacb8..f4150c97e2 100644 --- a/apps/insights/src/components/PriceComponentDrawer/index.tsx +++ b/apps/insights/src/components/PriceComponentDrawer/index.tsx @@ -35,15 +35,14 @@ import type { CategoricalChartState } from "recharts/types/chart/types"; import { z } from "zod"; import { Cluster, ClusterToName } from "../../services/pyth"; -import type { Status } from "../../status"; import ConformanceReport from "../ConformanceReport/conformance-report"; +import type { Interval } from "../ConformanceReport/types"; +import { useDownloadReportForFeed } from '../ConformanceReport/use-download-report-for-feed'; import { LiveComponentValue, LiveConfidence, LivePrice } from "../LivePrices"; import { PriceName } from "../PriceName"; import { Score } from "../Score"; -import { Status as StatusComponent } from "../Status"; +import { StatusLive } from "../Status"; import styles from "./index.module.scss"; -import type { Interval } from "../ConformanceReport/types"; -import { useDownloadReportForFeed } from "../ConformanceReport/use-download-report-for-feed"; const LineChart = dynamic( () => import("recharts").then((recharts) => recharts.LineChart), @@ -61,7 +60,6 @@ type PriceComponent = { feedKey: string; score: number | undefined; rank: number | undefined; - status: Status; identifiesPublisher?: boolean | undefined; firstEvaluation?: Date | undefined; cluster: Cluster; @@ -137,16 +135,20 @@ export const usePriceComponentDrawer = ({ ), headingAfter: (
- +
), contents: ( @@ -267,14 +269,14 @@ export const usePriceComponentDrawer = ({ }; type HeadingExtraProps = { - status: Status; identifiesPublisher?: boolean | undefined; cluster: Cluster; publisherKey: string; symbol: string; + feedKey: string; }; -const HeadingExtra = ({ status, ...props }: HeadingExtraProps) => { +const HeadingExtra = ({ feedKey, ...props }: HeadingExtraProps) => { const downloadReportForFeed = useDownloadReportForFeed(); const handleDownloadReport = useCallback( @@ -293,7 +295,11 @@ const HeadingExtra = ({ status, ...props }: HeadingExtraProps) => { <>
- +
{ + const publisherData = useLivePublishersData(feedKey); + if(!publisherData?.slot) { + return + } + return publisherData.slot; +}; + export const ResolvedPriceComponentsCard = < U extends string, T extends PriceComponent & Record, @@ -119,7 +134,6 @@ export const ResolvedPriceComponentsCard = < }) => { const logger = useLogger(); const collator = useCollator(); - const filter = useFilter({ sensitivity: "base", usage: "search" }); const { selectComponent } = usePriceComponentDrawer({ components: priceComponents, identifiesPublisher, @@ -158,7 +172,7 @@ export const ResolvedPriceComponentsCard = < mkPageLink, } = useQueryParamFilterPagination( componentsFilteredByStatus, - (component, search) => filter.contains(component.nameAsString, search), + ()=>true, (a, b, { column, direction }) => { switch (column) { case "score": @@ -186,6 +200,9 @@ export const ResolvedPriceComponentsCard = < } case "status": { + if (a.status === undefined || b.status === undefined) { + return 0; + } const resultByStatus = b.status - a.status; const result = resultByStatus === 0 @@ -200,7 +217,11 @@ export const ResolvedPriceComponentsCard = < } } }, - (items) => items, + (items, search) => { + return matchSorter(items, search, { + keys: ["nameAsString","feedKey"], + }); + }, { defaultPageSize: 50, defaultSort: "name", @@ -246,12 +267,7 @@ export const ResolvedPriceComponentsCard = < /> ), slot: ( - + ), price: ( ), - status: , + status: component.status !== undefined && ( + + ), }, })), [paginatedItems, props.extraColumns, selectComponent], @@ -293,25 +311,27 @@ export const ResolvedPriceComponentsCard = < ); return ( - + + + ); }; diff --git a/apps/insights/src/components/PriceFeed/publishers-card.tsx b/apps/insights/src/components/PriceFeed/publishers-card.tsx index 53b60ebc91..a9ccc75561 100644 --- a/apps/insights/src/components/PriceFeed/publishers-card.tsx +++ b/apps/insights/src/components/PriceFeed/publishers-card.tsx @@ -2,13 +2,15 @@ import { Switch } from "@pythnetwork/component-library/Switch"; import { useLogger } from "@pythnetwork/component-library/useLogger"; -import { useQueryState, parseAsBoolean } from "nuqs"; +import { parseAsBoolean, useQueryState } from "nuqs"; import { Suspense, useCallback, useMemo } from "react"; +import { useLivePriceData } from "../../hooks/use-live-price-data"; import { Cluster } from "../../services/pyth"; import type { PriceComponent } from "../PriceComponentsCard"; import { PriceComponentsCard } from "../PriceComponentsCard"; import { PublisherTag } from "../PublisherTag"; +import { getStatus } from "../Status"; type PublishersCardProps = | { isLoading: true } @@ -29,7 +31,10 @@ type ResolvedPublishersCardProps = { symbol: string; displaySymbol: string; assetClass: string; - publishers: Omit[]; + publishers: Omit< + PriceComponent, + "status" | "symbol" | "displaySymbol" | "assetClass" + >[]; metricsTime?: Date | undefined; }; @@ -38,6 +43,7 @@ const ResolvedPublishersCard = ({ ...props }: ResolvedPublishersCardProps) => { const logger = useLogger(); + const data = useLivePriceData(Cluster.Pythnet, publishers[0]?.feedKey); const [includeTestFeeds, setIncludeTestFeeds] = useQueryState( "includeTestFeeds", @@ -63,11 +69,20 @@ const ResolvedPublishersCard = ({ [includeTestFeeds, publishers], ); + const publishersWithStatus = useMemo(() => { + return publishersFilteredByCluster.map((publisher) => { + return { + ...publisher, + status: getStatus(data.current, publisher.publisherKey), + }; + }); + }, [publishersFilteredByCluster, data]); + return ( ); @@ -75,10 +90,14 @@ const ResolvedPublishersCard = ({ type PublishersCardImplProps = | { isLoading: true } - | (ResolvedPublishersCardProps & { + | (Omit & { isLoading?: false | undefined; includeTestFeeds: boolean; updateIncludeTestFeeds: (newValue: boolean) => void; + publishers: Omit< + PriceComponent, + "symbol" | "displaySymbol" | "assetClass" + >[]; }); const PublishersCardImpl = (props: PublishersCardImplProps) => ( diff --git a/apps/insights/src/components/PriceFeed/publishers.tsx b/apps/insights/src/components/PriceFeed/publishers.tsx index e15d8569ad..df5022a014 100644 --- a/apps/insights/src/components/PriceFeed/publishers.tsx +++ b/apps/insights/src/components/PriceFeed/publishers.tsx @@ -7,7 +7,6 @@ import { } from "../../server/pyth"; import { getRankingsBySymbol } from "../../services/clickhouse"; import { Cluster, ClusterToName } from "../../services/pyth"; -import { getStatus } from "../../status"; import { PublisherIcon } from "../PublisherIcon"; import { PublisherTag } from "../PublisherTag"; import { PublishersCard } from "./publishers-card"; @@ -34,7 +33,6 @@ export const Publishers = async ({ params }: Props) => { const metricsTime = pythnetPublishers.find( (publisher) => publisher.ranking !== undefined, )?.ranking?.time; - return feed === undefined ? ( notFound() ) : ( @@ -44,7 +42,7 @@ export const Publishers = async ({ params }: Props) => { displaySymbol={feed.product.display_symbol} assetClass={feed.product.asset_type} publishers={publishers.map( - ({ ranking, publisher, status, cluster, knownPublisher }) => ({ + ({ ranking, publisher, cluster, knownPublisher }) => ({ id: `${publisher}-${ClusterToName[cluster]}`, feedKey: cluster === Cluster.Pythnet @@ -55,7 +53,6 @@ export const Publishers = async ({ params }: Props) => { deviationScore: ranking?.deviation_score, stalledScore: ranking?.stalled_score, cluster, - status, publisherKey: publisher, rank: ranking?.final_rank, firstEvaluation: ranking?.first_ranking_time, @@ -94,7 +91,6 @@ const getPublishers = async (cluster: Cluster, symbol: string) => { return { ranking, publisher, - status: getStatus(ranking), cluster, knownPublisher: lookupPublisher(publisher), }; diff --git a/apps/insights/src/components/Publisher/get-price-feeds.tsx b/apps/insights/src/components/Publisher/get-price-feeds.tsx index 6a392badf7..a66bcc11c8 100644 --- a/apps/insights/src/components/Publisher/get-price-feeds.tsx +++ b/apps/insights/src/components/Publisher/get-price-feeds.tsx @@ -1,7 +1,6 @@ import { getFeedsForPublisherRequest } from "../../server/pyth"; import { getRankingsByPublisher } from "../../services/clickhouse"; import { Cluster, ClusterToName } from "../../services/pyth"; -import { getStatus } from "../../status"; export const getPriceFeeds = async (cluster: Cluster, key: string) => { const [feeds, rankings] = await Promise.all([ @@ -17,7 +16,6 @@ export const getPriceFeeds = async (cluster: Cluster, key: string) => { return { ranking, feed, - status: getStatus(ranking), }; }); }; diff --git a/apps/insights/src/components/Publisher/layout.tsx b/apps/insights/src/components/Publisher/layout.tsx index 3765d522c1..3d04131384 100644 --- a/apps/insights/src/components/Publisher/layout.tsx +++ b/apps/insights/src/components/Publisher/layout.tsx @@ -35,8 +35,8 @@ import { ChartCard } from "../ChartCard"; import { Explain } from "../Explain"; import { ExplainAverage, - ExplainActive, - ExplainInactive, + ExplainPermissioned, + ExplainUnpermissioned, } from "../Explanations"; import { FormattedNumber } from "../FormattedNumber"; import { PublisherIcon } from "../PublisherIcon"; @@ -376,8 +376,8 @@ const ActiveFeedsCard = async ({ ) : ( @@ -391,8 +391,8 @@ type ActiveFeedsCardImplProps = isLoading?: false | undefined; cluster: Cluster; publisherKey: string; - activeFeeds: number; - inactiveFeeds: number; + permissionedFeeds: number; + unpermissionedFeeds: number; allFeeds: number; }; @@ -400,14 +400,14 @@ const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => ( - Active Feeds - + Permissioned + } header2={ <> - - Inactive Feeds + Unpermissioned + } stat1={ @@ -415,10 +415,10 @@ const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => ( ) : ( - {props.activeFeeds} + {props.permissionedFeeds} ) } @@ -427,10 +427,10 @@ const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => ( ) : ( - {props.inactiveFeeds} + {props.unpermissionedFeeds} ) } @@ -441,7 +441,7 @@ const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => ( <> % @@ -454,7 +454,7 @@ const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => ( <> % @@ -463,9 +463,9 @@ const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => ( > {!props.isLoading && ( )} diff --git a/apps/insights/src/components/Publisher/performance.tsx b/apps/insights/src/components/Publisher/performance.tsx index 05da88e9fd..5e8ac08cf5 100644 --- a/apps/insights/src/components/Publisher/performance.tsx +++ b/apps/insights/src/components/Publisher/performance.tsx @@ -10,19 +10,15 @@ import { NoResults } from "@pythnetwork/component-library/NoResults"; import { Table } from "@pythnetwork/component-library/Table"; import { lookup } from "@pythnetwork/known-publishers"; import { notFound } from "next/navigation"; -import type { ReactNode, ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; -import { getPriceFeeds } from "./get-price-feeds"; -import styles from "./performance.module.scss"; -import { TopFeedsTable } from "./top-feeds-table"; import { getPublishers } from "../../services/clickhouse"; import type { Cluster } from "../../services/pyth"; import { ClusterToName, parseCluster } from "../../services/pyth"; -import { Status } from "../../status"; import { - ExplainActive, - ExplainInactive, ExplainAverage, + ExplainPermissioned, + ExplainUnpermissioned, } from "../Explanations"; import { PriceFeedIcon } from "../PriceFeedIcon"; import { PriceFeedTag } from "../PriceFeedTag"; @@ -30,6 +26,9 @@ import { PublisherIcon } from "../PublisherIcon"; import { PublisherTag } from "../PublisherTag"; import { Ranking } from "../Ranking"; import { Score } from "../Score"; +import { getPriceFeeds } from "./get-price-feeds"; +import styles from "./performance.module.scss"; +import { TopFeedsTable } from "./top-feeds-table"; const PUBLISHER_SCORE_WIDTH = 24; @@ -68,22 +67,22 @@ export const Performance = async ({ params }: Props) => { {publisher.rank} ), - activeFeeds: ( + permissionedFeeds: ( - {publisher.activeFeeds} + {publisher.permissionedFeeds} ), - inactiveFeeds: ( + unpermissionedFeeds: ( - {publisher.inactiveFeeds} + {publisher.unpermissionedFeeds} ), averageScore: ( @@ -147,8 +146,8 @@ type PerformanceImplProps = typeof Table< | "ranking" | "averageScore" - | "activeFeeds" - | "inactiveFeeds" + | "permissionedFeeds" + | "unpermissionedFeeds" | "name" > >["rows"] @@ -175,8 +174,8 @@ const PerformanceImpl = (props: PerformanceImplProps) => ( fields={[ { id: "ranking", name: "Ranking" }, { id: "averageScore", name: "Average Score" }, - { id: "activeFeeds", name: "Active Feeds" }, - { id: "inactiveFeeds", name: "Inactive Feeds" }, + { id: "permissionedFeeds", name: "Permissioned" }, + { id: "unpermissionedFeeds", name: "Unpermissioned" }, ]} {...(props.isLoading ? { isLoading: true } @@ -207,22 +206,22 @@ const PerformanceImpl = (props: PerformanceImplProps) => ( loadingSkeleton: , }, { - id: "activeFeeds", + id: "permissionedFeeds", name: ( <> - ACTIVE FEEDS - + PERMISSIONED FEEDS + ), alignment: "center", width: 30, }, { - id: "inactiveFeeds", + id: "unpermissionedFeeds", name: ( <> - INACTIVE FEEDS - + UNPERMISSIONED FEEDS + ), alignment: "center", @@ -292,27 +291,23 @@ const getFeedRows = ( >; })[], ) => - priceFeeds - .filter((feed) => feed.status === Status.Active) - .slice(0, 20) - .map(({ feed, ranking, status }) => ({ - key: feed.product.price_account, - symbol: feed.symbol, - displaySymbol: feed.product.display_symbol, - description: feed.product.description, - assetClass: feed.product.asset_type, - score: ranking.final_score, - rank: ranking.final_rank, - status, - firstEvaluation: ranking.first_ranking_time, - icon: ( - - ), - href: `/price-feeds/${encodeURIComponent(feed.symbol)}`, - })); + priceFeeds.slice(0, 20).map(({ feed, ranking }) => ({ + key: feed.product.price_account, + symbol: feed.symbol, + displaySymbol: feed.product.display_symbol, + description: feed.product.description, + assetClass: feed.product.asset_type, + score: ranking.final_score, + rank: ranking.final_rank, + firstEvaluation: ranking.first_ranking_time, + icon: ( + + ), + href: `/price-feeds/${encodeURIComponent(feed.symbol)}`, + })); const sliceAround = ( arr: T[], diff --git a/apps/insights/src/components/Publisher/price-feeds.tsx b/apps/insights/src/components/Publisher/price-feeds.tsx index c1dfb0313e..86d9500b09 100644 --- a/apps/insights/src/components/Publisher/price-feeds.tsx +++ b/apps/insights/src/components/Publisher/price-feeds.tsx @@ -27,13 +27,12 @@ export const PriceFeeds = async ({ params }: Props) => { const feeds = await getPriceFeeds(parsedCluster, key); const metricsTime = feeds.find((feed) => feed.ranking !== undefined)?.ranking ?.time; - return ( ({ + priceFeeds={feeds.map(({ ranking, feed }) => ({ symbol: feed.symbol, name: ( { } /> ), + lastSlot: feed.price.lastSlot, score: ranking?.final_score, rank: ranking?.final_rank, uptimeScore: ranking?.uptime_score, deviationScore: ranking?.deviation_score, stalledScore: ranking?.stalled_score, - status, feedKey: feed.product.price_account, nameAsString: feed.product.display_symbol, id: feed.product.price_account, @@ -72,7 +71,7 @@ type PriceFeedsCardProps = isLoading?: false | undefined; publisherKey: string; cluster: Cluster; - priceFeeds: Omit[]; + priceFeeds: Omit[]; metricsTime?: Date | undefined; }; diff --git a/apps/insights/src/components/Publisher/top-feeds-table.tsx b/apps/insights/src/components/Publisher/top-feeds-table.tsx index 7a9d7a6c7d..d04dec0fb0 100644 --- a/apps/insights/src/components/Publisher/top-feeds-table.tsx +++ b/apps/insights/src/components/Publisher/top-feeds-table.tsx @@ -6,13 +6,12 @@ import { Table } from "@pythnetwork/component-library/Table"; import type { ReactNode } from "react"; import { useMemo } from "react"; -import styles from "./top-feeds-table.module.scss"; import type { Cluster } from "../../services/pyth"; -import type { Status } from "../../status"; import { AssetClassBadge } from "../AssetClassBadge"; import { usePriceComponentDrawer } from "../PriceComponentDrawer"; import { PriceFeedTag } from "../PriceFeedTag"; import { Score } from "../Score"; +import styles from "./top-feeds-table.module.scss"; type Props = | LoadingTopFeedsTableImplProps @@ -36,7 +35,6 @@ type ResolvedTopFeedsTableProps = BaseTopFeedsTableImplProps & { assetClass: string; score: number; rank: number; - status: Status; firstEvaluation: Date; icon: ReactNode; href: string; diff --git a/apps/insights/src/components/Publishers/index.tsx b/apps/insights/src/components/Publishers/index.tsx index c48e901e88..340e52fc30 100644 --- a/apps/insights/src/components/Publishers/index.tsx +++ b/apps/insights/src/components/Publishers/index.tsx @@ -33,6 +33,8 @@ export const Publishers = async () => { ]); const rankingTime = pythnetPublishers[0]?.timestamp; const scoreTime = pythnetPublishers[0]?.scoreTime; + // eslint-disable-next-line no-console + console.log({ pythnetPublishers, pythtestConformancePublishers }); return (
@@ -147,7 +149,7 @@ const toTableRow = ({ key, rank, permissionedFeeds, - activeFeeds, + unpermissionedFeeds, averageScore, }: Awaited>[number]) => { const knownPublisher = lookupPublisher(key); @@ -155,7 +157,7 @@ const toTableRow = ({ id: key, ranking: rank, permissionedFeeds, - activeFeeds, + unpermissionedFeeds, averageScore, ...(knownPublisher && { name: knownPublisher.name, diff --git a/apps/insights/src/components/Publishers/publishers-card.tsx b/apps/insights/src/components/Publishers/publishers-card.tsx index 4a39ad29de..084abde27b 100644 --- a/apps/insights/src/components/Publishers/publishers-card.tsx +++ b/apps/insights/src/components/Publishers/publishers-card.tsx @@ -5,7 +5,6 @@ import { Database } from "@phosphor-icons/react/dist/ssr/Database"; import { Badge } from "@pythnetwork/component-library/Badge"; import { Card } from "@pythnetwork/component-library/Card"; import { EntityList } from "@pythnetwork/component-library/EntityList"; -import { Link } from "@pythnetwork/component-library/Link"; import { NoResults } from "@pythnetwork/component-library/NoResults"; import { Paginator } from "@pythnetwork/component-library/Paginator"; import { SearchInput } from "@pythnetwork/component-library/SearchInput"; @@ -27,8 +26,8 @@ import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filte import { CLUSTER_NAMES } from "../../services/pyth"; import { ExplainPermissioned, - ExplainActive, ExplainRanking, + ExplainUnpermissioned, } from "../Explanations"; import { PublisherTag } from "../PublisherTag"; import { Ranking } from "../Ranking"; @@ -47,7 +46,7 @@ type Publisher = { id: string; ranking: number; permissionedFeeds: number; - activeFeeds: number; + unpermissionedFeeds: number; averageScore: number; } & ( | { name: string; icon: ReactNode } @@ -80,7 +79,6 @@ const ResolvedPublishersCard = ({ "cluster", parseAsStringEnum([...CLUSTER_NAMES]).withDefault("pythnet"), ); - const { search, sortDescriptor, @@ -103,7 +101,7 @@ const ResolvedPublishersCard = ({ switch (column) { case "ranking": case "permissionedFeeds": - case "activeFeeds": + case "unpermissionedFeeds": case "averageScore": { return ( (direction === "descending" ? -1 : 1) * (a[column] - b[column]) @@ -136,7 +134,7 @@ const ResolvedPublishersCard = ({ ranking, averageScore, permissionedFeeds, - activeFeeds, + unpermissionedFeeds, ...publisher }) => ({ id, @@ -155,15 +153,7 @@ const ResolvedPublishersCard = ({ /> ), permissionedFeeds, - activeFeeds: ( - - {activeFeeds} - - ), + unpermissionedFeeds, averageScore: ( ), @@ -226,7 +216,7 @@ type PublishersCardContentsProps = Pick & | "ranking" | "name" | "permissionedFeeds" - | "activeFeeds" + | "unpermissionedFeeds" | "averageScore" > & { textValue: string })[]; } @@ -303,7 +293,7 @@ const PublishersCardContents = ({ fields={[ { id: "averageScore", name: "Average Score" }, { id: "permissionedFeeds", name: "Permissioned Feeds" }, - { id: "activeFeeds", name: "Active Feeds" }, + { id: "unpermissionedFeeds", name: "Unpermissioned Feeds" }, ]} isLoading={props.isLoading} rows={ @@ -360,11 +350,11 @@ const PublishersCardContents = ({ allowsSorting: true, }, { - id: "activeFeeds", + id: "unpermissionedFeeds", name: ( <> - ACTIVE - + UNPERMISSIONED + ), alignment: "center", diff --git a/apps/insights/src/components/Status/index.tsx b/apps/insights/src/components/Status/index.tsx index 921cd3b966..1147b938d7 100644 --- a/apps/insights/src/components/Status/index.tsx +++ b/apps/insights/src/components/Status/index.tsx @@ -1,5 +1,9 @@ +import type { PriceData } from "@pythnetwork/client"; import { Status as StatusComponent } from "@pythnetwork/component-library/Status"; +import { useMemo } from "react"; +import { useLivePriceData } from "../../hooks/use-live-price-data"; +import type { Cluster } from "../../services/pyth"; import { Status as StatusType } from "../../status"; export const Status = ({ status }: { status: StatusType }) => ( @@ -7,31 +11,67 @@ export const Status = ({ status }: { status: StatusType }) => ( {getText(status)} ); +export const StatusLive = ({ + cluster, + feedKey, + publisherKey, +}: { + cluster: Cluster; + feedKey: string; + publisherKey: string; +}) => { + const status = useGetStatus(cluster, feedKey, publisherKey); + if (!status) { + return; + } + return ; +}; + +const useGetStatus = ( + cluster: Cluster, + feedKey: string, + publisherKey: string, +) => { + const data = useLivePriceData(cluster, feedKey); + return useMemo(() => { + return getStatus(data.current, publisherKey); + }, [data.current, feedKey, publisherKey]); +}; + +export const getStatus = ( + currentPriceData: PriceData | undefined, + publisherKey: string, +) => { + if (!currentPriceData) { + return; + } + const lastPublishedSlot = currentPriceData.priceComponents.find( + (price) => price.publisher.toString() === publisherKey, + )?.latest.publishSlot; + const isPublisherInactive = + Number(lastPublishedSlot ?? 0) < Number(currentPriceData.validSlot) - 100; + + return isPublisherInactive ? StatusType.Down : StatusType.Live; +}; const getVariant = (status: StatusType) => { switch (status) { - case StatusType.Active: { + case StatusType.Live: { return "success"; } - case StatusType.Inactive: { + case StatusType.Down: { return "error"; } - case StatusType.Unranked: { - return "disabled"; - } } }; const getText = (status: StatusType) => { switch (status) { - case StatusType.Active: { - return "Active"; - } - case StatusType.Inactive: { - return "Inactive"; + case StatusType.Live: { + return "Live"; } - case StatusType.Unranked: { - return "Unranked"; + case StatusType.Down: { + return "Down"; } } }; diff --git a/apps/insights/src/hooks/use-live-price-data.tsx b/apps/insights/src/hooks/use-live-price-data.tsx index 9a57a5262c..6f4a36c01a 100644 --- a/apps/insights/src/hooks/use-live-price-data.tsx +++ b/apps/insights/src/hooks/use-live-price-data.tsx @@ -35,7 +35,7 @@ export const LivePriceDataProvider = (props: LivePriceDataProviderProps) => { return ; }; -export const useLivePriceData = (cluster: Cluster, feedKey: string) => { +export const useLivePriceData = (cluster: Cluster, feedKey?: string) => { const { addSubscription, removeSubscription } = useLivePriceDataContext()[cluster]; @@ -45,6 +45,9 @@ export const useLivePriceData = (cluster: Cluster, feedKey: string) => { }>({ current: undefined, prev: undefined }); useEffect(() => { + if (!feedKey) { + return; + } addSubscription(feedKey, setData); return () => { removeSubscription(feedKey, setData); diff --git a/apps/insights/src/hooks/use-live-publishers-data.tsx b/apps/insights/src/hooks/use-live-publishers-data.tsx new file mode 100644 index 0000000000..27e05f79b0 --- /dev/null +++ b/apps/insights/src/hooks/use-live-publishers-data.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { + createContext, + use, + useEffect, + useState +} from "react"; + +import { PythSubscriber } from '../services/pyth-stream'; + +type PublisherFeedData = Record + +const LivePublishersDataContext = createContext< + PublisherFeedData | undefined +>(undefined); + +type LivePublishersDataProviderProps = { + publisherKey: string; + children: React.ReactNode; +} + +export const LivePublishersDataProvider = ({ publisherKey, children }: LivePublishersDataProviderProps) => { + const [localPublishersData, setLocalPublishersData] = useState({}); + useEffect(() => { + const pythSubscriber = new PythSubscriber(); + + pythSubscriber.onPublisherUpdate((update) => { + setLocalPublishersData((prev) => { + const newData = { ...prev }; + for (const u of update.updates) { + if(u.feed_id === '7jAVut34sgRj6erznsYvLYvjc9GJwXTpN88ThZSDJ65G') { + console.log("update", u); + } + newData[u.feed_id] = { price: u.price, slot: BigInt(u.slot) }; + } + return newData; + }); + }); + pythSubscriber.connect().then( + () => {pythSubscriber.subscribePublisher([publisherKey]);} + ).catch((error) => { + console.error("Failed to subscribe to publisher", error); + }); + return () => { + pythSubscriber.disconnect(); + }; + }, [publisherKey]); + return {children}; +}; + +export const useLivePublishersData = (feedKey: string) => { + const publisherData = useLivePublishersDataContext() + return publisherData[feedKey]; +}; + +const useLivePublishersDataContext = () => { + const publisherData = use(LivePublishersDataContext); + if (publisherData === undefined) { + throw new LivePublishersDataProviderNotInitializedError(); + } + return publisherData; +}; + +class LivePublishersDataProviderNotInitializedError extends Error { + constructor() { + super("This component must be a child of "); + this.name = "LivePublishersDataProviderNotInitializedError"; + } +} \ No newline at end of file diff --git a/apps/insights/src/services/clickhouse.ts b/apps/insights/src/services/clickhouse.ts index c0b3b2521a..8e60a32e79 100644 --- a/apps/insights/src/services/clickhouse.ts +++ b/apps/insights/src/services/clickhouse.ts @@ -19,10 +19,7 @@ const _getPublishers = async (cluster: Cluster) => permissionedFeeds: z .string() .transform((value) => Number.parseInt(value, 10)), - activeFeeds: z - .string() - .transform((value) => Number.parseInt(value, 10)), - inactiveFeeds: z + unpermissionedFeeds: z .string() .transform((value) => Number.parseInt(value, 10)), averageScore: z.number(), @@ -36,9 +33,7 @@ const _getPublishers = async (cluster: Cluster) => SELECT publisher, time, - avg(final_score) AS averageScore, - countIf(uptime_score >= 0.5) AS activeFeeds, - countIf(uptime_score < 0.5) AS inactiveFeeds + avg(final_score) AS averageScore FROM publisher_quality_ranking WHERE cluster = {cluster:String} AND time = ( @@ -55,8 +50,7 @@ const _getPublishers = async (cluster: Cluster) => publisher AS key, rank, LENGTH(symbols) AS permissionedFeeds, - activeFeeds, - inactiveFeeds, + (SELECT count(symbol) FROM symbols WHERE cluster = {cluster:String}) - LENGTH(symbols) AS unpermissionedFeeds, score_data.averageScore, score_data.time as scoreTime FROM publishers_ranking diff --git a/apps/insights/src/services/pyth-stream.ts b/apps/insights/src/services/pyth-stream.ts new file mode 100644 index 0000000000..c52307ea14 --- /dev/null +++ b/apps/insights/src/services/pyth-stream.ts @@ -0,0 +1,161 @@ +// ─── Client → Server ─────────────────────────────────────────────────────────── + +type ClientMessage = + | { type: "subscribe_price"; ids: string[]; verbose: boolean } + | { type: "unsubscribe_price"; ids: string[]; verbose: boolean } + | { type: "subscribe_publisher"; ids: string[]; verbose: boolean } + | { type: "unsubscribe_publisher"; ids: string[]; verbose: boolean }; + +// ─── Types for price feeds ───────────────────────────────────────────────────── + +export type PriceInfo = { + price: string; + conf: string; + expo: number; + publish_time: number; + slot: number; +}; + +export type PriceFeed = { + id: string; + price: PriceInfo; + ema_price: PriceInfo; +}; + +// ─── Server → Client (single) ───────────────────────────────────────────────── + +export type PriceUpdate = { + type: "price_update"; + price_feed: PriceFeed; +}; + + +// ─── Server → Client (batched) ──────────────────────────────────────────────── +// Batch frame that contains many publisher updates in one message. +export type PublisherPriceUpdateItem = { + publisher: string; + feed_id: string; + price: string; + slot: number; +}; + +export type PublisherPriceUpdate = { + type: "publisher_price_update"; + updates: PublisherPriceUpdateItem[]; +}; + +// Server can send a single message, a batch message, or an array of messages. +export type ServerMessage = + | PriceUpdate + | PublisherPriceUpdate; + +export type ServerPayload = ServerMessage | ServerMessage[]; + +export class PythSubscriber { + private ws: WebSocket | undefined = undefined; + private url: string; + + private onPriceUpdateHandler?: (update: PriceUpdate) => void; + private onPublisherUpdateHandler?: (update: PublisherPriceUpdate) => void; + + constructor(url = "ws://0.0.0.0:8080") { + this.url = url; + } + + public async connect() { + return new Promise((resolve, reject) => { + if (this.ws) return resolve(); + + this.ws = new WebSocket(this.url); + + this.ws.addEventListener("open", () => { + console.log("Connected to WebSocket"); + resolve(); + }); + + this.ws.addEventListener("message", (event: MessageEvent) => { + try { + const data = JSON.parse(event.data) as ServerPayload; + + if (Array.isArray(data)) { + for (const msg of data) this.handleServerMessage(msg); + } else { + this.handleServerMessage(data); + } + } catch (e) { + console.error("Failed to parse message:", event.data, e); + } + }); + + this.ws.addEventListener("close", () => { + console.warn("WebSocket closed"); + this.ws = undefined; + }); + + this.ws.addEventListener("error", (event: Event) => { + console.error("WebSocket error:", event); + }); + }); + } + + private handleServerMessage(msg: ServerMessage) { + switch (msg.type) { + case "price_update": + this.onPriceUpdateHandler?.(msg); + return; + + case "publisher_price_update": + // Prefer batch handler if provided; otherwise fan out to per-item handler + if (this.onPublisherUpdateHandler) { + this.onPublisherUpdateHandler(msg); + } + return; + + default: + console.error("Unknown message from server:", msg); + } + } + + private send(msg: ClientMessage) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } else { + console.warn("WebSocket not ready. Message not sent:", msg); + } + } + + // ── Subscriptions ─────────────────────────────────────────────────────────── + + public subscribePrice(ids: string[], verbose = true) { + this.send({ type: "subscribe_price", ids, verbose }); + } + + public unsubscribePrice(ids: string[], verbose = true) { + this.send({ type: "unsubscribe_price", ids, verbose }); + } + + public subscribePublisher(ids: string[], verbose = true) { + this.send({ type: "subscribe_publisher", ids, verbose }); + } + + public unsubscribePublisher(ids: string[], verbose = true) { + this.send({ type: "unsubscribe_publisher", ids, verbose }); + } + + // ── Callbacks ─────────────────────────────────────────────────────────────── + + public onPriceUpdate(cb: (update: PriceUpdate) => void) { + this.onPriceUpdateHandler = cb; + } + + public onPublisherUpdate( + cb: (update: PublisherPriceUpdate) => void + ) { + this.onPublisherUpdateHandler = cb; + } + + public disconnect() { + this.ws?.close(); + this.ws = undefined; + } +} \ No newline at end of file diff --git a/apps/insights/src/services/pyth/index.ts b/apps/insights/src/services/pyth/index.ts index 54de1966fa..761156331f 100644 --- a/apps/insights/src/services/pyth/index.ts +++ b/apps/insights/src/services/pyth/index.ts @@ -7,6 +7,7 @@ import type { PythPriceCallback } from "@pythnetwork/client/lib/PythConnection"; import { Connection, PublicKey } from "@solana/web3.js"; import { PYTHNET_RPC, PYTHTEST_CONFORMANCE_RPC } from "../../config/isomorphic"; +import { getPythMetadata } from './get-metadata'; export enum Cluster { Pythnet, @@ -79,3 +80,27 @@ export const subscribe = ( pythConn.onPriceChange(cb); return pythConn; }; + + +// const testWebsocket = () => { +// clients[Cluster.Pythnet].getData().then((metadata) => { +// console.log(metadata); +// }); +// console.log("Test websocket"); +// const ws = new WebSocket('ws://0.0.0.0:8080'); +// ws.onopen = (event) => { +// console.log("WebSocket opened"); +// ws.send(JSON.stringify({"type":"subscribe_publisher","ids":["6DNocjFJjocPLZnKBZyEJAC5o2QaiT5Mx8AkphfxDm5i"],"verbose":true })); +// }; +// ws.onmessage = (event) => { +// console.log("WebSocket message received", event.data); +// }; +// ws.onerror = (event) => { +// console.log("WebSocket error", event); +// }; +// ws.onclose = (event) => { +// console.log(ws); +// } +// } + +// testWebsocket(); \ No newline at end of file diff --git a/apps/insights/src/status.ts b/apps/insights/src/status.ts index 96940a5fc2..8d508ae08c 100644 --- a/apps/insights/src/status.ts +++ b/apps/insights/src/status.ts @@ -1,35 +1,22 @@ export enum Status { - Unranked, - Inactive, - Active, + Down, + Live, } -export const getStatus = (ranking?: { uptime_score: number }): Status => { - if (ranking) { - 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", + [Status.Live]: "Live", + [Status.Down]: "Down", } 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 "Live": { + return Status.Live; } - case "Unranked": { - return Status.Unranked; + case "Down": { + return Status.Down; } default: { return undefined;