From 0d176976e09201d6eb97c0407d9cbe6e4d4ef454 Mon Sep 17 00:00:00 2001 From: Alexandru Cambose Date: Mon, 25 Aug 2025 10:56:47 +0200 Subject: [PATCH 1/5] feat: status updates --- .../[publisher]/route.ts | 2 +- .../components/PriceComponentsCard/index.tsx | 1 + .../components/PriceFeed/publishers-card.tsx | 22 ++++++++++++-- .../src/components/PriceFeed/publishers.tsx | 6 +--- .../components/Publisher/get-price-feeds.tsx | 2 -- .../src/components/Publisher/price-feeds.tsx | 13 ++++----- apps/insights/src/components/Status/index.tsx | 24 +++++++-------- .../src/hooks/use-live-price-data.tsx | 5 +++- apps/insights/src/services/clickhouse.ts | 2 -- apps/insights/src/status.ts | 29 +++++-------------- 10 files changed, 53 insertions(+), 53 deletions(-) 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..65c6b27025 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,7 @@ export const GET = async ( const filteredFeeds = feeds.filter((feed) => feed.price.priceComponents.some((c) => c.publisher === publisher), ); - +console.log({filteredFeeds: filteredFeeds.length, feeds: feeds.length}) return new Response(stringify(filteredFeeds), { headers: { "Content-Type": "application/json", diff --git a/apps/insights/src/components/PriceComponentsCard/index.tsx b/apps/insights/src/components/PriceComponentsCard/index.tsx index 60e3b16385..5ff4774281 100644 --- a/apps/insights/src/components/PriceComponentsCard/index.tsx +++ b/apps/insights/src/components/PriceComponentsCard/index.tsx @@ -120,6 +120,7 @@ export const ResolvedPriceComponentsCard = < const logger = useLogger(); const collator = useCollator(); const filter = useFilter({ sensitivity: "base", usage: "search" }); + const { selectComponent } = usePriceComponentDrawer({ components: priceComponents, identifiesPublisher, diff --git a/apps/insights/src/components/PriceFeed/publishers-card.tsx b/apps/insights/src/components/PriceFeed/publishers-card.tsx index 53b60ebc91..470266b58c 100644 --- a/apps/insights/src/components/PriceFeed/publishers-card.tsx +++ b/apps/insights/src/components/PriceFeed/publishers-card.tsx @@ -9,6 +9,8 @@ import { Cluster } from "../../services/pyth"; import type { PriceComponent } from "../PriceComponentsCard"; import { PriceComponentsCard } from "../PriceComponentsCard"; import { PublisherTag } from "../PublisherTag"; +import { useLivePriceData } from '../../hooks/use-live-price-data'; +import { Status } from '../../status'; type PublishersCardProps = | { isLoading: true } @@ -29,7 +31,7 @@ type ResolvedPublishersCardProps = { symbol: string; displaySymbol: string; assetClass: string; - publishers: Omit[]; + publishers: Omit[]; metricsTime?: Date | undefined; }; @@ -38,6 +40,7 @@ const ResolvedPublishersCard = ({ ...props }: ResolvedPublishersCardProps) => { const logger = useLogger(); +const data = useLivePriceData(Cluster.Pythnet, publishers[0]?.feedKey); const [includeTestFeeds, setIncludeTestFeeds] = useQueryState( "includeTestFeeds", @@ -63,11 +66,26 @@ const ResolvedPublishersCard = ({ [includeTestFeeds, publishers], ); + const publishersWithStatus = useMemo(() => { + const currentSlot = data.current?.validSlot; + const isInactive = (publishSlot: number, currentSlot: number) => publishSlot < currentSlot - 100; + + return publishersFilteredByCluster.map((publisher) => { + const lastPublishedSlot = data.current?.priceComponents.find((price) => price.publisher.toString() === publisher.publisherKey.toString())?.latest.publishSlot; + const isPublisherInactive = isInactive(Number(lastPublishedSlot ?? 0), Number(currentSlot ?? 0)); + + return { + ...publisher, + status: isPublisherInactive ? Status.Down : Status.Live, + }; + }); + }, [publishersFilteredByCluster, data]); + return ( ); 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/price-feeds.tsx b/apps/insights/src/components/Publisher/price-feeds.tsx index c1dfb0313e..ba7bc8902c 100644 --- a/apps/insights/src/components/Publisher/price-feeds.tsx +++ b/apps/insights/src/components/Publisher/price-feeds.tsx @@ -8,6 +8,7 @@ import type { PriceComponent } from "../PriceComponentsCard"; import { PriceComponentsCard } from "../PriceComponentsCard"; import { PriceFeedIcon } from "../PriceFeedIcon"; import { PriceFeedTag } from "../PriceFeedTag"; +import { useLivePriceData } from '../../hooks/use-live-price-data'; type Props = { params: Promise<{ @@ -33,7 +34,7 @@ export const PriceFeeds = async ({ params }: Props) => { metricsTime={metricsTime} publisherKey={key} cluster={parsedCluster} - priceFeeds={feeds.map(({ ranking, feed, status }) => ({ + priceFeeds={feeds.map(({ ranking, feed }) => ({ symbol: feed.symbol, name: ( { 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,12 +72,12 @@ type PriceFeedsCardProps = isLoading?: false | undefined; publisherKey: string; cluster: Cluster; - priceFeeds: Omit[]; + priceFeeds: Omit[]; metricsTime?: Date | undefined; }; -const PriceFeedsCard = (props: PriceFeedsCardProps) => ( - +} @@ -103,5 +103,4 @@ const PriceFeedsCard = (props: PriceFeedsCardProps) => ( ), })), })} - /> -); + /> \ No newline at end of file diff --git a/apps/insights/src/components/Status/index.tsx b/apps/insights/src/components/Status/index.tsx index 921cd3b966..ada1f868b2 100644 --- a/apps/insights/src/components/Status/index.tsx +++ b/apps/insights/src/components/Status/index.tsx @@ -10,28 +10,28 @@ export const Status = ({ status }: { status: StatusType }) => ( 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"; - } + // 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"; } + // case StatusType.Unranked: { + // return "Unranked"; + // } } }; diff --git a/apps/insights/src/hooks/use-live-price-data.tsx b/apps/insights/src/hooks/use-live-price-data.tsx index 9a57a5262c..c08715d424 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/services/clickhouse.ts b/apps/insights/src/services/clickhouse.ts index c0b3b2521a..862ef8474b 100644 --- a/apps/insights/src/services/clickhouse.ts +++ b/apps/insights/src/services/clickhouse.ts @@ -37,8 +37,6 @@ const _getPublishers = async (cluster: Cluster) => 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 = ( 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; From d90eafc9a9f13801f50ae196b4146bfb2e9d5914 Mon Sep 17 00:00:00 2001 From: Alexandru Cambose Date: Tue, 26 Aug 2025 12:12:34 +0200 Subject: [PATCH 2/5] feat: working statuses --- .../[publisher]/route.ts | 1 - .../src/components/Explanations/index.tsx | 60 +++++--------- .../components/PriceComponentDrawer/index.tsx | 22 +++-- .../components/PriceComponentsCard/index.tsx | 9 +- .../components/PriceFeed/publishers-card.tsx | 29 +++---- .../src/components/Publisher/layout.tsx | 36 ++++---- .../src/components/Publisher/performance.tsx | 83 +++++++++---------- .../src/components/Publisher/price-feeds.tsx | 1 - .../components/Publisher/top-feeds-table.tsx | 4 +- .../src/components/Publishers/index.tsx | 4 +- .../components/Publishers/publishers-card.tsx | 30 +++---- apps/insights/src/components/Status/index.tsx | 57 +++++++++++-- .../src/hooks/use-live-price-data.tsx | 2 +- apps/insights/src/services/clickhouse.ts | 10 +-- apps/insights/src/status.ts | 5 ++ 15 files changed, 185 insertions(+), 168 deletions(-) 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 65c6b27025..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), ); -console.log({filteredFeeds: filteredFeeds.length, feeds: feeds.length}) 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/PriceComponentDrawer/index.tsx b/apps/insights/src/components/PriceComponentDrawer/index.tsx index c608e1a769..b6385fabde 100644 --- a/apps/insights/src/components/PriceComponentDrawer/index.tsx +++ b/apps/insights/src/components/PriceComponentDrawer/index.tsx @@ -36,11 +36,10 @@ import { z } from "zod"; import styles from "./index.module.scss"; import { Cluster, ClusterToName } from "../../services/pyth"; -import type { Status } from "../../status"; import { LiveConfidence, LivePrice, LiveComponentValue } from "../LivePrices"; import { PriceName } from "../PriceName"; import { Score } from "../Score"; -import { Status as StatusComponent } from "../Status"; +import { StatusLive } from "../Status"; const LineChart = dynamic( () => import("recharts").then((recharts) => recharts.LineChart), @@ -58,7 +57,6 @@ type PriceComponent = { feedKey: string; score: number | undefined; rank: number | undefined; - status: Status; identifiesPublisher?: boolean | undefined; firstEvaluation?: Date | undefined; cluster: Cluster; @@ -134,16 +132,20 @@ export const usePriceComponentDrawer = ({ ), headingAfter: (
- +
), contents: ( @@ -264,18 +266,22 @@ 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) => { return ( <>
- +
), - status: , + status: component.status !== undefined && ( + + ), }, })), [paginatedItems, props.extraColumns, selectComponent], diff --git a/apps/insights/src/components/PriceFeed/publishers-card.tsx b/apps/insights/src/components/PriceFeed/publishers-card.tsx index 470266b58c..cf5ed176e0 100644 --- a/apps/insights/src/components/PriceFeed/publishers-card.tsx +++ b/apps/insights/src/components/PriceFeed/publishers-card.tsx @@ -2,15 +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 { useLivePriceData } from '../../hooks/use-live-price-data'; -import { Status } from '../../status'; +import { getStatus } from "../Status"; type PublishersCardProps = | { isLoading: true } @@ -31,7 +31,10 @@ type ResolvedPublishersCardProps = { symbol: string; displaySymbol: string; assetClass: string; - publishers: Omit[]; + publishers: Omit< + PriceComponent, + "status" | "symbol" | "displaySymbol" | "assetClass" + >[]; metricsTime?: Date | undefined; }; @@ -40,7 +43,7 @@ const ResolvedPublishersCard = ({ ...props }: ResolvedPublishersCardProps) => { const logger = useLogger(); -const data = useLivePriceData(Cluster.Pythnet, publishers[0]?.feedKey); + const data = useLivePriceData(Cluster.Pythnet, publishers[0]?.feedKey); const [includeTestFeeds, setIncludeTestFeeds] = useQueryState( "includeTestFeeds", @@ -67,16 +70,10 @@ const data = useLivePriceData(Cluster.Pythnet, publishers[0]?.feedKey); ); const publishersWithStatus = useMemo(() => { - const currentSlot = data.current?.validSlot; - const isInactive = (publishSlot: number, currentSlot: number) => publishSlot < currentSlot - 100; - return publishersFilteredByCluster.map((publisher) => { - const lastPublishedSlot = data.current?.priceComponents.find((price) => price.publisher.toString() === publisher.publisherKey.toString())?.latest.publishSlot; - const isPublisherInactive = isInactive(Number(lastPublishedSlot ?? 0), Number(currentSlot ?? 0)); - return { - ...publisher, - status: isPublisherInactive ? Status.Down : Status.Live, + ...publisher, + status: getStatus(data.current, publisher.publisherKey), }; }); }, [publishersFilteredByCluster, data]); @@ -93,10 +90,14 @@ const data = useLivePriceData(Cluster.Pythnet, publishers[0]?.feedKey); 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/Publisher/layout.tsx b/apps/insights/src/components/Publisher/layout.tsx index 0d60f9a27b..1b180cac86 100644 --- a/apps/insights/src/components/Publisher/layout.tsx +++ b/apps/insights/src/components/Publisher/layout.tsx @@ -30,8 +30,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"; @@ -350,8 +350,8 @@ const ActiveFeedsCard = async ({ ) : ( @@ -365,8 +365,8 @@ type ActiveFeedsCardImplProps = isLoading?: false | undefined; cluster: Cluster; publisherKey: string; - activeFeeds: number; - inactiveFeeds: number; + permissionedFeeds: number; + unpermissionedFeeds: number; allFeeds: number; }; @@ -374,14 +374,14 @@ const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => ( - Active Feeds - + Permissioned + } header2={ <> - - Inactive Feeds + Unpermissioned + } stat1={ @@ -389,10 +389,10 @@ const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => ( ) : ( - {props.activeFeeds} + {props.permissionedFeeds} ) } @@ -401,10 +401,10 @@ const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => ( ) : ( - {props.inactiveFeeds} + {props.unpermissionedFeeds} ) } @@ -415,7 +415,7 @@ const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => ( <> % @@ -428,7 +428,7 @@ const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => ( <> % @@ -437,9 +437,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..9454b293ea 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 Feeds" }, + { id: "unpermissionedFeeds", name: "Unpermissioned Feeds" }, ]} {...(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 ba7bc8902c..1d96308c40 100644 --- a/apps/insights/src/components/Publisher/price-feeds.tsx +++ b/apps/insights/src/components/Publisher/price-feeds.tsx @@ -8,7 +8,6 @@ import type { PriceComponent } from "../PriceComponentsCard"; import { PriceComponentsCard } from "../PriceComponentsCard"; import { PriceFeedIcon } from "../PriceFeedIcon"; import { PriceFeedTag } from "../PriceFeedTag"; -import { useLivePriceData } from '../../hooks/use-live-price-data'; type Props = { params: Promise<{ 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..48aa460fb4 100644 --- a/apps/insights/src/components/Publishers/index.tsx +++ b/apps/insights/src/components/Publishers/index.tsx @@ -147,7 +147,7 @@ const toTableRow = ({ key, rank, permissionedFeeds, - activeFeeds, + unpermissionedFeeds, averageScore, }: Awaited>[number]) => { const knownPublisher = lookupPublisher(key); @@ -155,7 +155,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 90596a35aa..0ad67b55b5 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]) @@ -135,7 +133,7 @@ const ResolvedPublishersCard = ({ ranking, averageScore, permissionedFeeds, - activeFeeds, + unpermissionedFeeds, ...publisher }) => ({ id, @@ -154,15 +152,7 @@ const ResolvedPublishersCard = ({ /> ), permissionedFeeds, - activeFeeds: ( - - {activeFeeds} - - ), + unpermissionedFeeds, averageScore: ( ), @@ -225,7 +215,7 @@ type PublishersCardContentsProps = Pick & | "ranking" | "name" | "permissionedFeeds" - | "activeFeeds" + | "unpermissionedFeeds" | "averageScore" > & { textValue: string })[]; } @@ -302,7 +292,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={ @@ -359,11 +349,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 ada1f868b2..82adaacf3a 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 }) => ( @@ -8,6 +12,47 @@ export const Status = ({ status }: { status: StatusType }) => ( ); +export const StatusLive = ({ + cluster, + feedKey, + publisherKey, +}: { + cluster: Cluster; + feedKey: string; + publisherKey: string; +}) => { + const status = useGetStatus(cluster, feedKey, publisherKey); + + 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 StatusType.Unknown; + } + 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.Live: { @@ -16,9 +61,9 @@ const getVariant = (status: StatusType) => { case StatusType.Down: { return "error"; } - // case StatusType.Unranked: { - // return "disabled"; - // } + case StatusType.Unknown: { + return "disabled"; + } } }; @@ -30,8 +75,8 @@ const getText = (status: StatusType) => { case StatusType.Down: { return "Down"; } - // case StatusType.Unranked: { - // return "Unranked"; - // } + case StatusType.Unknown: { + return "Unknown"; + } } }; diff --git a/apps/insights/src/hooks/use-live-price-data.tsx b/apps/insights/src/hooks/use-live-price-data.tsx index c08715d424..6f4a36c01a 100644 --- a/apps/insights/src/hooks/use-live-price-data.tsx +++ b/apps/insights/src/hooks/use-live-price-data.tsx @@ -45,7 +45,7 @@ export const useLivePriceData = (cluster: Cluster, feedKey?: string) => { }>({ current: undefined, prev: undefined }); useEffect(() => { - if(!feedKey) { + if (!feedKey) { return; } addSubscription(feedKey, setData); diff --git a/apps/insights/src/services/clickhouse.ts b/apps/insights/src/services/clickhouse.ts index 862ef8474b..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,7 +33,7 @@ const _getPublishers = async (cluster: Cluster) => SELECT publisher, time, - avg(final_score) AS averageScore, + avg(final_score) AS averageScore FROM publisher_quality_ranking WHERE cluster = {cluster:String} AND time = ( @@ -53,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/status.ts b/apps/insights/src/status.ts index 8d508ae08c..da9d568d56 100644 --- a/apps/insights/src/status.ts +++ b/apps/insights/src/status.ts @@ -1,11 +1,13 @@ export enum Status { Down, Live, + Unknown, } export const STATUS_NAMES = { [Status.Live]: "Live", [Status.Down]: "Down", + [Status.Unknown]: "Unknown", } as const; export type StatusName = (typeof STATUS_NAMES)[Status]; @@ -18,6 +20,9 @@ export const statusNameToStatus = (name: string): Status | undefined => { case "Down": { return Status.Down; } + case "Unknown": { + return Status.Unknown; + } default: { return undefined; } From 6523c450c9f1ba612dca7e466929c2c063ca803c Mon Sep 17 00:00:00 2001 From: Alexandru Cambose Date: Tue, 26 Aug 2025 12:17:37 +0200 Subject: [PATCH 3/5] fix: formatting --- .../src/components/PriceComponentsCard/index.tsx | 2 +- .../src/components/PriceFeed/publishers-card.tsx | 10 +++++----- apps/insights/src/components/Publisher/price-feeds.tsx | 7 ++++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/insights/src/components/PriceComponentsCard/index.tsx b/apps/insights/src/components/PriceComponentsCard/index.tsx index 7c4ea0a1a5..0d2008a9ff 100644 --- a/apps/insights/src/components/PriceComponentsCard/index.tsx +++ b/apps/insights/src/components/PriceComponentsCard/index.tsx @@ -187,7 +187,7 @@ export const ResolvedPriceComponentsCard = < } case "status": { - if(a.status === undefined || b.status === undefined) { + if (a.status === undefined || b.status === undefined) { return 0; } const resultByStatus = b.status - a.status; diff --git a/apps/insights/src/components/PriceFeed/publishers-card.tsx b/apps/insights/src/components/PriceFeed/publishers-card.tsx index cf5ed176e0..a9ccc75561 100644 --- a/apps/insights/src/components/PriceFeed/publishers-card.tsx +++ b/apps/insights/src/components/PriceFeed/publishers-card.tsx @@ -90,14 +90,14 @@ const ResolvedPublishersCard = ({ type PublishersCardImplProps = | { isLoading: true } - | (Omit & { + | (Omit & { isLoading?: false | undefined; includeTestFeeds: boolean; updateIncludeTestFeeds: (newValue: boolean) => void; - publishers: Omit< - PriceComponent, - "symbol" | "displaySymbol" | "assetClass" - >[]; + publishers: Omit< + PriceComponent, + "symbol" | "displaySymbol" | "assetClass" + >[]; }); const PublishersCardImpl = (props: PublishersCardImplProps) => ( diff --git a/apps/insights/src/components/Publisher/price-feeds.tsx b/apps/insights/src/components/Publisher/price-feeds.tsx index 1d96308c40..660c3a9f8f 100644 --- a/apps/insights/src/components/Publisher/price-feeds.tsx +++ b/apps/insights/src/components/Publisher/price-feeds.tsx @@ -75,8 +75,8 @@ type PriceFeedsCardProps = metricsTime?: Date | undefined; }; -const PriceFeedsCard = (props: PriceFeedsCardProps) => - ( + } @@ -102,4 +102,5 @@ const PriceFeedsCard = (props: PriceFeedsCardProps) => ), })), })} - /> \ No newline at end of file + /> +); From 35629de5a823780a84094356523463a85126d141 Mon Sep 17 00:00:00 2001 From: Alexandru Cambose Date: Tue, 26 Aug 2025 13:02:29 +0200 Subject: [PATCH 4/5] fix: unknown label --- .../src/components/Publisher/performance.tsx | 4 ++-- apps/insights/src/components/Publishers/index.tsx | 2 ++ apps/insights/src/components/Status/index.tsx | 13 ++++--------- apps/insights/src/status.ts | 5 ----- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/apps/insights/src/components/Publisher/performance.tsx b/apps/insights/src/components/Publisher/performance.tsx index 9454b293ea..5e8ac08cf5 100644 --- a/apps/insights/src/components/Publisher/performance.tsx +++ b/apps/insights/src/components/Publisher/performance.tsx @@ -174,8 +174,8 @@ const PerformanceImpl = (props: PerformanceImplProps) => ( fields={[ { id: "ranking", name: "Ranking" }, { id: "averageScore", name: "Average Score" }, - { id: "permissionedFeeds", name: "Permissioned Feeds" }, - { id: "unpermissionedFeeds", name: "Unpermissioned Feeds" }, + { id: "permissionedFeeds", name: "Permissioned" }, + { id: "unpermissionedFeeds", name: "Unpermissioned" }, ]} {...(props.isLoading ? { isLoading: true } diff --git a/apps/insights/src/components/Publishers/index.tsx b/apps/insights/src/components/Publishers/index.tsx index 48aa460fb4..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 (
diff --git a/apps/insights/src/components/Status/index.tsx b/apps/insights/src/components/Status/index.tsx index 82adaacf3a..1147b938d7 100644 --- a/apps/insights/src/components/Status/index.tsx +++ b/apps/insights/src/components/Status/index.tsx @@ -11,7 +11,6 @@ export const Status = ({ status }: { status: StatusType }) => ( {getText(status)} ); - export const StatusLive = ({ cluster, feedKey, @@ -22,7 +21,9 @@ export const StatusLive = ({ publisherKey: string; }) => { const status = useGetStatus(cluster, feedKey, publisherKey); - + if (!status) { + return; + } return ; }; @@ -42,7 +43,7 @@ export const getStatus = ( publisherKey: string, ) => { if (!currentPriceData) { - return StatusType.Unknown; + return; } const lastPublishedSlot = currentPriceData.priceComponents.find( (price) => price.publisher.toString() === publisherKey, @@ -61,9 +62,6 @@ const getVariant = (status: StatusType) => { case StatusType.Down: { return "error"; } - case StatusType.Unknown: { - return "disabled"; - } } }; @@ -75,8 +73,5 @@ const getText = (status: StatusType) => { case StatusType.Down: { return "Down"; } - case StatusType.Unknown: { - return "Unknown"; - } } }; diff --git a/apps/insights/src/status.ts b/apps/insights/src/status.ts index da9d568d56..8d508ae08c 100644 --- a/apps/insights/src/status.ts +++ b/apps/insights/src/status.ts @@ -1,13 +1,11 @@ export enum Status { Down, Live, - Unknown, } export const STATUS_NAMES = { [Status.Live]: "Live", [Status.Down]: "Down", - [Status.Unknown]: "Unknown", } as const; export type StatusName = (typeof STATUS_NAMES)[Status]; @@ -20,9 +18,6 @@ export const statusNameToStatus = (name: string): Status | undefined => { case "Down": { return Status.Down; } - case "Unknown": { - return Status.Unknown; - } default: { return undefined; } From a1091671bfd49b1cf493a1c9cbcfb3668eede711 Mon Sep 17 00:00:00 2001 From: Alexandru Cambose Date: Fri, 12 Sep 2025 14:44:59 +0100 Subject: [PATCH 5/5] feat: added websocket listener --- .../src/components/FormattedNumber/index.tsx | 2 +- .../src/components/LivePrices/index.tsx | 2 +- .../components/PriceComponentDrawer/index.tsx | 4 +- .../components/PriceComponentsCard/index.tsx | 84 +++++---- .../src/components/Publisher/price-feeds.tsx | 2 +- .../src/hooks/use-live-publishers-data.tsx | 73 ++++++++ apps/insights/src/services/pyth-stream.ts | 161 ++++++++++++++++++ apps/insights/src/services/pyth/index.ts | 25 +++ 8 files changed, 313 insertions(+), 40 deletions(-) create mode 100644 apps/insights/src/hooks/use-live-publishers-data.tsx create mode 100644 apps/insights/src/services/pyth-stream.ts 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 e151982c9b..f4150c97e2 100644 --- a/apps/insights/src/components/PriceComponentDrawer/index.tsx +++ b/apps/insights/src/components/PriceComponentDrawer/index.tsx @@ -37,7 +37,7 @@ import { z } from "zod"; import { Cluster, ClusterToName } from "../../services/pyth"; import ConformanceReport from "../ConformanceReport/conformance-report"; import type { Interval } from "../ConformanceReport/types"; -import { useDownloadReportForFeed } from "../ConformanceReport/use-download-report-for-feed"; +import { useDownloadReportForFeed } from '../ConformanceReport/use-download-report-for-feed'; import { LiveComponentValue, LiveConfidence, LivePrice } from "../LivePrices"; import { PriceName } from "../PriceName"; import { Score } from "../Score"; @@ -276,7 +276,7 @@ type HeadingExtraProps = { feedKey: string; }; -const HeadingExtra = ({ status, ...props }: HeadingExtraProps) => { +const HeadingExtra = ({ feedKey, ...props }: HeadingExtraProps) => { const downloadReportForFeed = useDownloadReportForFeed(); const handleDownloadReport = useCallback( diff --git a/apps/insights/src/components/PriceComponentsCard/index.tsx b/apps/insights/src/components/PriceComponentsCard/index.tsx index bbd7ba28d0..1a4bfc4edc 100644 --- a/apps/insights/src/components/PriceComponentsCard/index.tsx +++ b/apps/insights/src/components/PriceComponentsCard/index.tsx @@ -10,19 +10,20 @@ import { SearchInput } from "@pythnetwork/component-library/SearchInput"; import { Select } from "@pythnetwork/component-library/Select"; import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup"; import type { - RowConfig, ColumnConfig, + RowConfig, SortDescriptor, } from "@pythnetwork/component-library/Table"; import { Table } from "@pythnetwork/component-library/Table"; import { useLogger } from "@pythnetwork/component-library/useLogger"; import clsx from "clsx"; -import { useQueryState, parseAsStringEnum, parseAsBoolean } from "nuqs"; +import { parseAsBoolean, parseAsStringEnum, useQueryState } from "nuqs"; import type { ReactNode } from "react"; -import { Fragment, Suspense, useMemo, useCallback } from "react"; -import { useFilter, useCollator } from "react-aria"; +import { Fragment, Suspense, useCallback, useMemo } from "react"; +import { useCollator } from "react-aria"; -import styles from "./index.module.scss"; +import { matchSorter } from 'match-sorter'; +import { LivePublishersDataProvider, useLivePublishersData } from '../../hooks/use-live-publishers-data'; import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination"; import { Cluster } from "../../services/pyth"; import type { StatusName } from "../../status"; @@ -34,11 +35,12 @@ import { import { Explain } from "../Explain"; import { EvaluationTime } from "../Explanations"; import { FormattedNumber } from "../FormattedNumber"; -import { LivePrice, LiveConfidence, LiveComponentValue } from "../LivePrices"; +import { LiveComponentValue, LiveConfidence, LivePrice } from "../LivePrices"; import { usePriceComponentDrawer } from "../PriceComponentDrawer"; import { PriceName } from "../PriceName"; import { Score } from "../Score"; import { Status as StatusComponent } from "../Status"; +import styles from "./index.module.scss"; const SCORE_WIDTH = 32; @@ -106,6 +108,19 @@ export const PriceComponentsCard = < } }; +const LiveSlot = ({ feedKey, publisherKey, cluster }: { feedKey: string, publisherKey: string, cluster: Cluster }) => { + const publisherData = useLivePublishersData(feedKey); + if(!publisherData?.slot) { + return + } + return publisherData.slot; +}; + export const ResolvedPriceComponentsCard = < U extends string, T extends PriceComponent & Record, @@ -119,8 +134,6 @@ export const ResolvedPriceComponentsCard = < }) => { const logger = useLogger(); const collator = useCollator(); - const filter = useFilter({ sensitivity: "base", usage: "search" }); - const { selectComponent } = usePriceComponentDrawer({ components: priceComponents, identifiesPublisher, @@ -159,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": @@ -204,7 +217,11 @@ export const ResolvedPriceComponentsCard = < } } }, - (items) => items, + (items, search) => { + return matchSorter(items, search, { + keys: ["nameAsString","feedKey"], + }); + }, { defaultPageSize: 50, defaultSort: "name", @@ -250,12 +267,7 @@ export const ResolvedPriceComponentsCard = < /> ), slot: ( - + ), price: ( + + + ); }; diff --git a/apps/insights/src/components/Publisher/price-feeds.tsx b/apps/insights/src/components/Publisher/price-feeds.tsx index 660c3a9f8f..86d9500b09 100644 --- a/apps/insights/src/components/Publisher/price-feeds.tsx +++ b/apps/insights/src/components/Publisher/price-feeds.tsx @@ -27,7 +27,6 @@ export const PriceFeeds = async ({ params }: Props) => { const feeds = await getPriceFeeds(parsedCluster, key); const metricsTime = feeds.find((feed) => feed.ranking !== undefined)?.ranking ?.time; - return ( { } /> ), + lastSlot: feed.price.lastSlot, score: ranking?.final_score, rank: ranking?.final_rank, uptimeScore: ranking?.uptime_score, 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/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