diff --git a/apps/insights/src/app/price-feeds/[slug]/price-components/[componentId]/page.tsx b/apps/insights/src/app/price-feeds/[slug]/price-components/[componentId]/page.tsx deleted file mode 100644 index 7fa2197792..0000000000 --- a/apps/insights/src/app/price-feeds/[slug]/price-components/[componentId]/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -type Props = { - params: Promise<{ - componentId: string; - }>; -}; - -const PriceFeedComponent = async ({ params }: Props) => { - const { componentId } = await params; - return componentId; -}; -export default PriceFeedComponent; diff --git a/apps/insights/src/app/price-feeds/[slug]/price-components/layout.tsx b/apps/insights/src/app/price-feeds/[slug]/price-components/layout.tsx deleted file mode 100644 index cd1074e5ff..0000000000 --- a/apps/insights/src/app/price-feeds/[slug]/price-components/layout.tsx +++ /dev/null @@ -1 +0,0 @@ -export { PriceComponents as default } from "../../../../components/PriceFeed/price-components"; diff --git a/apps/insights/src/app/price-feeds/[slug]/price-components/page.tsx b/apps/insights/src/app/price-feeds/[slug]/price-components/page.tsx deleted file mode 100644 index 657fc7d676..0000000000 --- a/apps/insights/src/app/price-feeds/[slug]/price-components/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -// eslint-disable-next-line unicorn/no-null -const Page = () => null; -export default Page; diff --git a/apps/insights/src/app/price-feeds/[slug]/publishers/page.tsx b/apps/insights/src/app/price-feeds/[slug]/publishers/page.tsx new file mode 100644 index 0000000000..eb43e8bfc4 --- /dev/null +++ b/apps/insights/src/app/price-feeds/[slug]/publishers/page.tsx @@ -0,0 +1 @@ +export { Publishers as default } from "../../../../components/PriceFeed/publishers"; diff --git a/apps/insights/src/app/price-feeds/layout.ts b/apps/insights/src/app/price-feeds/layout.ts index 146de5525c..cbaf9a9d4b 100644 --- a/apps/insights/src/app/price-feeds/layout.ts +++ b/apps/insights/src/app/price-feeds/layout.ts @@ -1 +1 @@ -export { PriceFeedsLayout as default } from "../../components/PriceFeeds/layout"; +export { ZoomLayoutTransition as default } from "../../components/ZoomLayoutTransition"; diff --git a/apps/insights/src/app/publishers/[key]/error.ts b/apps/insights/src/app/publishers/[key]/error.ts new file mode 100644 index 0000000000..4f357cc1ba --- /dev/null +++ b/apps/insights/src/app/publishers/[key]/error.ts @@ -0,0 +1,3 @@ +"use client"; + +export { Error as default } from "../../../components/Error"; diff --git a/apps/insights/src/app/publishers/[key]/layout.ts b/apps/insights/src/app/publishers/[key]/layout.ts new file mode 100644 index 0000000000..6ff206be80 --- /dev/null +++ b/apps/insights/src/app/publishers/[key]/layout.ts @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export { PublishersLayout as default } from "../../../components/Publisher/layout"; +import { getPublishers } from "../../../services/clickhouse"; + +export const metadata: Metadata = { + title: "Publishers", +}; + +export const generateStaticParams = async () => { + const publishers = await getPublishers(); + return publishers.map(({ key }) => ({ key })); +}; diff --git a/apps/insights/src/app/publishers/[key]/page.ts b/apps/insights/src/app/publishers/[key]/page.ts new file mode 100644 index 0000000000..2b90356716 --- /dev/null +++ b/apps/insights/src/app/publishers/[key]/page.ts @@ -0,0 +1 @@ +export { Performance as default } from "../../../components/Publisher/performance"; diff --git a/apps/insights/src/app/publishers/[key]/price-feeds/page.tsx b/apps/insights/src/app/publishers/[key]/price-feeds/page.tsx new file mode 100644 index 0000000000..776a1cb5df --- /dev/null +++ b/apps/insights/src/app/publishers/[key]/price-feeds/page.tsx @@ -0,0 +1 @@ +export { PriceFeeds as default } from "../../../../components/Publisher/price-feeds"; diff --git a/apps/insights/src/app/publishers/layout.ts b/apps/insights/src/app/publishers/layout.ts new file mode 100644 index 0000000000..cbaf9a9d4b --- /dev/null +++ b/apps/insights/src/app/publishers/layout.ts @@ -0,0 +1 @@ +export { ZoomLayoutTransition as default } from "../../components/ZoomLayoutTransition"; diff --git a/apps/insights/src/app/yesterdays-prices/route.ts b/apps/insights/src/app/yesterdays-prices/route.ts index 119dc97ab5..5595d4811e 100644 --- a/apps/insights/src/app/yesterdays-prices/route.ts +++ b/apps/insights/src/app/yesterdays-prices/route.ts @@ -1,25 +1,11 @@ import type { NextRequest } from "next/server"; -import { z } from "zod"; -import { client } from "../../services/clickhouse"; +import { getYesterdaysPrices } from "../../services/clickhouse"; export async function GET(req: NextRequest) { const symbols = req.nextUrl.searchParams.getAll("symbols"); - const rows = await client.query({ - query: - "select symbol, price from insights_yesterdays_prices(symbols={symbols: Array(String)})", - query_params: { symbols }, - }); - const result = await rows.json(); - const data = schema.parse(result.data); + const data = await getYesterdaysPrices(symbols); return Response.json( Object.fromEntries(data.map(({ symbol, price }) => [symbol, price])), ); } - -const schema = z.array( - z.object({ - symbol: z.string(), - price: z.number(), - }), -); diff --git a/apps/insights/src/components/ChangePercent/index.tsx b/apps/insights/src/components/ChangePercent/index.tsx index 79635d088f..1e3e3d7a8d 100644 --- a/apps/insights/src/components/ChangePercent/index.tsx +++ b/apps/insights/src/components/ChangePercent/index.tsx @@ -1,14 +1,11 @@ "use client"; -import { CaretUp } from "@phosphor-icons/react/dist/ssr/CaretUp"; -import { Skeleton } from "@pythnetwork/component-library/Skeleton"; -import clsx from "clsx"; import { type ComponentProps, createContext, use } from "react"; import { useNumberFormatter } from "react-aria"; import { z } from "zod"; -import styles from "./index.module.scss"; import { StateType, useData } from "../../use-data"; +import { ChangeValue } from "../ChangeValue"; import { useLivePrice } from "../LivePrices"; const ONE_SECOND_IN_MS = 1000; @@ -16,8 +13,6 @@ const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS; const ONE_HOUR_IN_MS = 60 * ONE_MINUTE_IN_MS; const REFRESH_YESTERDAYS_PRICES_INTERVAL = ONE_HOUR_IN_MS; -const CHANGE_PERCENT_SKELETON_WIDTH = 15; - type Props = Omit, "value"> & { feeds: (Feed & { symbol: string })[]; }; @@ -92,12 +87,7 @@ export const ChangePercent = ({ feed, className }: ChangePercentProps) => { case StateType.Loading: case StateType.NotLoaded: { - return ( - - ); + return ; } case StateType.Loaded: { @@ -107,7 +97,7 @@ export const ChangePercent = ({ feed, className }: ChangePercentProps) => { // eslint-disable-next-line unicorn/no-null return yesterdaysPrice === undefined ? null : ( @@ -130,7 +120,7 @@ const ChangePercentLoaded = ({ const currentPrice = useLivePrice(feed); return currentPrice === undefined ? ( - + ) : ( - + {numberFormatter.format( - (100 * Math.abs(currentPrice - priorPrice)) / currentPrice, + (100 * Math.abs(currentPrice - priorPrice)) / priorPrice, )} % - + ); }; diff --git a/apps/insights/src/components/ChangePercent/index.module.scss b/apps/insights/src/components/ChangeValue/index.module.scss similarity index 96% rename from apps/insights/src/components/ChangePercent/index.module.scss rename to apps/insights/src/components/ChangeValue/index.module.scss index 2005df4aa0..af47ccc0aa 100644 --- a/apps/insights/src/components/ChangePercent/index.module.scss +++ b/apps/insights/src/components/ChangeValue/index.module.scss @@ -1,6 +1,6 @@ @use "@pythnetwork/component-library/theme"; -.changePercent { +.changeValue { transition: color 100ms linear; display: flex; flex-flow: row nowrap; diff --git a/apps/insights/src/components/ChangeValue/index.tsx b/apps/insights/src/components/ChangeValue/index.tsx new file mode 100644 index 0000000000..c60adfc6e6 --- /dev/null +++ b/apps/insights/src/components/ChangeValue/index.tsx @@ -0,0 +1,46 @@ +import { CaretUp } from "@phosphor-icons/react/dist/ssr/CaretUp"; +import { Skeleton } from "@pythnetwork/component-library/Skeleton"; +import clsx from "clsx"; +import type { ComponentProps } from "react"; + +import styles from "./index.module.scss"; + +const SKELETON_WIDTH = 15; + +type OwnProps = + | { isLoading: true; skeletonWidth?: number | undefined } + | { + isLoading?: false; + direction: "up" | "down" | "flat"; + }; + +type Props = Omit, keyof OwnProps> & OwnProps; + +export const ChangeValue = ({ className, children, ...props }: Props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { isLoading, ...propsWithoutIsLoading } = props; + return ( + + {children} + + ); +}; + +const Contents = (props: Props) => { + if (props.isLoading) { + return ; + } else if (props.direction === "flat") { + return "-"; + } else { + return ( + <> + + {props.children} + + ); + } +}; diff --git a/apps/insights/src/components/CopyButton/index.tsx b/apps/insights/src/components/CopyButton/index.tsx index 3a38047103..f002ccbbbc 100644 --- a/apps/insights/src/components/CopyButton/index.tsx +++ b/apps/insights/src/components/CopyButton/index.tsx @@ -7,8 +7,9 @@ import { type Props as ButtonProps, Button, } from "@pythnetwork/component-library/Button"; +import type { Button as UnstyledButton } from "@pythnetwork/component-library/unstyled/Button"; import clsx from "clsx"; -import { type ElementType, useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import styles from "./index.module.scss"; @@ -16,18 +17,13 @@ type OwnProps = { text: string; }; -type Props = Omit< - ButtonProps, +type Props = Omit< + ButtonProps, keyof OwnProps | "onPress" | "afterIcon" > & OwnProps; -export const CopyButton = ({ - text, - children, - className, - ...props -}: Props) => { +export const CopyButton = ({ text, children, className, ...props }: Props) => { const [isCopied, setIsCopied] = useState(false); const logger = useLogger(); const copy = useCallback(() => { diff --git a/apps/insights/src/components/FormattedDate/index.tsx b/apps/insights/src/components/FormattedDate/index.tsx new file mode 100644 index 0000000000..b04d64450e --- /dev/null +++ b/apps/insights/src/components/FormattedDate/index.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useMemo } from "react"; +import { useDateFormatter } from "react-aria"; + +type Props = Parameters[0] & { + value: Date; +}; + +export const FormattedDate = ({ value, ...args }: Props) => { + const numberFormatter = useDateFormatter(args); + return useMemo(() => numberFormatter.format(value), [numberFormatter, value]); +}; diff --git a/apps/insights/src/components/Meter/index.module.scss b/apps/insights/src/components/Meter/index.module.scss new file mode 100644 index 0000000000..ebc0595bab --- /dev/null +++ b/apps/insights/src/components/Meter/index.module.scss @@ -0,0 +1,34 @@ +@use "@pythnetwork/component-library/theme"; + +.meter { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(2); + + .labels { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: center; + + @include theme.text("base", "medium"); + } + + .score { + height: theme.spacing(3); + width: 100%; + border-radius: theme.border-radius("full"); + position: relative; + display: inline-block; + background-color: theme.color("button", "outline", "background", "hover"); + + .fill { + position: absolute; + top: 0; + bottom: 0; + left: 0; + border-radius: theme.border-radius("full"); + background: theme.color("chart", "series", "primary"); + } + } +} diff --git a/apps/insights/src/components/Meter/index.tsx b/apps/insights/src/components/Meter/index.tsx new file mode 100644 index 0000000000..662fa8b47f --- /dev/null +++ b/apps/insights/src/components/Meter/index.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Meter as MeterComponent } from "@pythnetwork/component-library/unstyled/Meter"; +import type { ComponentProps, ReactNode } from "react"; + +import styles from "./index.module.scss"; + +type OwnProps = { + label: string; + startLabel?: ReactNode | undefined; + endLabel?: ReactNode | undefined; +}; +type Props = Omit, keyof OwnProps> & + OwnProps; + +export const Meter = ({ label, startLabel, endLabel, ...props }: Props) => ( + + {({ percentage }) => ( +
+ {(startLabel !== undefined || endLabel !== undefined) && ( +
+ {startLabel ??
} + {endLabel ??
} +
+ )} +
+
+
+
+ )} + +); diff --git a/apps/insights/src/components/PriceFeed/price-components-card.tsx b/apps/insights/src/components/PriceComponentsCard/index.tsx similarity index 78% rename from apps/insights/src/components/PriceFeed/price-components-card.tsx rename to apps/insights/src/components/PriceComponentsCard/index.tsx index 4ed7103576..d81b0abeb3 100644 --- a/apps/insights/src/components/PriceFeed/price-components-card.tsx +++ b/apps/insights/src/components/PriceComponentsCard/index.tsx @@ -2,31 +2,33 @@ import { Card } from "@pythnetwork/component-library/Card"; import { Paginator } from "@pythnetwork/component-library/Paginator"; -import { Switch } from "@pythnetwork/component-library/Switch"; import { type RowConfig, type SortDescriptor, Table, } from "@pythnetwork/component-library/Table"; -import { type ReactNode, Suspense, useMemo, useState } from "react"; +import { type ReactNode, Suspense, useMemo } from "react"; import { useFilter, useCollator } from "react-aria"; import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination"; import { FormattedNumber } from "../FormattedNumber"; +import rootStyles from "../Root/index.module.scss"; import { Score } from "../Score"; -const PUBLISHER_SCORE_WIDTH = 24; +const SCORE_WIDTH = 24; type Props = { className?: string | undefined; + toolbar?: ReactNode; + defaultSort: string; + defaultDescending?: boolean | undefined; priceComponents: PriceComponent[]; nameLoadingSkeleton: ReactNode; - slug: string; }; type PriceComponent = { id: string; - publisherNameAsString: string | undefined; + nameAsString: string | undefined; score: number; name: ReactNode; uptimeScore: number; @@ -34,39 +36,22 @@ type PriceComponent = { deviationScore: number; stalledPenalty: number; stalledScore: number; - isTest: boolean; }; -export const PriceComponentsCard = ({ - priceComponents, - slug, - ...props -}: Props) => ( +export const PriceComponentsCard = ({ priceComponents, ...props }: Props) => ( }> - + ); const ResolvedPriceComponentsCard = ({ priceComponents, - slug, + defaultSort, + defaultDescending, ...props }: Props) => { const collator = useCollator(); const filter = useFilter({ sensitivity: "base", usage: "search" }); - const [includeTestComponents, setIncludeTestComponents] = useState(false); - - const filteredPriceComponents = useMemo( - () => - includeTestComponents - ? priceComponents - : priceComponents.filter((component) => !component.isTest), - [includeTestComponents, priceComponents], - ); const { search, @@ -82,11 +67,11 @@ const ResolvedPriceComponentsCard = ({ numPages, mkPageLink, } = useQueryParamFilterPagination( - filteredPriceComponents, + priceComponents, (priceComponent, search) => filter.contains(priceComponent.id, search) || - (priceComponent.publisherNameAsString !== undefined && - filter.contains(priceComponent.publisherNameAsString, search)), + (priceComponent.nameAsString !== undefined && + filter.contains(priceComponent.nameAsString, search)), (a, b, { column, direction }) => { switch (column) { case "score": @@ -117,10 +102,7 @@ const ResolvedPriceComponentsCard = ({ case "name": { return ( (direction === "descending" ? -1 : 1) * - collator.compare( - a.publisherNameAsString ?? a.id, - b.publisherNameAsString ?? b.id, - ) + collator.compare(a.nameAsString ?? a.id, b.nameAsString ?? b.id) ); } @@ -131,8 +113,8 @@ const ResolvedPriceComponentsCard = ({ }, { defaultPageSize: 20, - defaultSort: "score", - defaultDescending: true, + defaultSort, + defaultDescending: defaultDescending ?? false, }, ); @@ -150,10 +132,9 @@ const ResolvedPriceComponentsCard = ({ ...data }) => ({ id, - href: `/price-feeds/${slug}/price-components/${id}`, data: { ...data, - score: , + score: , uptimeScore: ( & ( | { isLoading: true } | { isLoading?: false; - includeTestComponents: boolean; - setIncludeTestComponents: (newValue: boolean) => void; numResults: number; search: string; sortDescriptor: SortDescriptor; @@ -248,23 +225,13 @@ type PriceComponentsCardProps = Pick< const PriceComponentsCardContents = ({ className, nameLoadingSkeleton, + toolbar, ...props }: PriceComponentsCardProps) => ( - Show test components - - } + toolbar={toolbar} {...(!props.isLoading && { footer: ( , + width: SCORE_WIDTH, + loadingSkeleton: , allowsSorting: true, }, { diff --git a/apps/insights/src/components/PriceFeed/layout.tsx b/apps/insights/src/components/PriceFeed/layout.tsx index 4dd992d762..497db857a7 100644 --- a/apps/insights/src/components/PriceFeed/layout.tsx +++ b/apps/insights/src/components/PriceFeed/layout.tsx @@ -13,7 +13,6 @@ import type { ReactNode } from "react"; import styles from "./layout.module.scss"; import { PriceFeedSelect } from "./price-feed-select"; import { ReferenceData } from "./reference-data"; -import { TabPanel, TabRoot, Tabs } from "./tabs"; import { toHex } from "../../hex"; import { getData } from "../../services/pyth"; import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent"; @@ -25,6 +24,7 @@ import { LiveValue, } from "../LivePrices"; import { PriceFeedTag } from "../PriceFeedTag"; +import { TabPanel, TabRoot, Tabs } from "../Tabs"; type Props = { children: ReactNode; @@ -86,7 +86,7 @@ export const PriceFeedLayout = async ({ children, params }: Props) => { - + @@ -149,14 +149,14 @@ export const PriceFeedLayout = async ({ children, params }: Props) => { - Price Components + Publishers diff --git a/apps/insights/src/components/PriceFeed/price-component-drawer.tsx b/apps/insights/src/components/PriceFeed/price-component-drawer.tsx deleted file mode 100644 index a17b6595a9..0000000000 --- a/apps/insights/src/components/PriceFeed/price-component-drawer.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import { Drawer } from "@pythnetwork/component-library/Drawer"; -import { - useSelectedLayoutSegment, - usePathname, - useRouter, -} from "next/navigation"; -import { type ReactNode, useMemo, useCallback } from "react"; - -type Props = { - children: ReactNode; -}; - -export const PriceComponentDrawer = ({ children }: Props) => { - const pathname = usePathname(); - const segment = useSelectedLayoutSegment(); - const prevUrl = useMemo( - () => - segment ? pathname.replace(new RegExp(`/${segment}$`), "") : pathname, - [pathname, segment], - ); - const router = useRouter(); - - const onOpenChange = useCallback( - (isOpen: boolean) => { - if (!isOpen) { - router.push(prevUrl); - } - }, - [router, prevUrl], - ); - - return ( - - {children} - - ); -}; diff --git a/apps/insights/src/components/PriceFeed/price-components.tsx b/apps/insights/src/components/PriceFeed/price-components.tsx deleted file mode 100644 index 44a77236ea..0000000000 --- a/apps/insights/src/components/PriceFeed/price-components.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Badge } from "@pythnetwork/component-library/Badge"; -import { lookup as lookupPublisher } from "@pythnetwork/known-publishers"; -import { notFound } from "next/navigation"; -import type { ReactNode } from "react"; - -import { PriceComponentDrawer } from "./price-component-drawer"; -import { PriceComponentsCard } from "./price-components-card"; -import styles from "./price-components.module.scss"; -import { getRankings } from "../../services/clickhouse"; -import { getData } from "../../services/pyth"; -import { PublisherTag } from "../PublisherTag"; - -type Props = { - children: ReactNode; - params: Promise<{ - slug: string; - }>; -}; - -export const PriceComponents = async ({ children, params }: Props) => { - const { slug } = await params; - const symbol = decodeURIComponent(slug); - const [data, rankings] = await Promise.all([getData(), getRankings(symbol)]); - const feed = data.find((feed) => feed.symbol === symbol); - - return feed ? ( - <> - ({ - id: ranking.publisher, - publisherNameAsString: lookupPublisher(ranking.publisher)?.name, - score: ranking.final_score, - isTest: ranking.cluster === "pythtest-conformance", - name: ( -
- - {ranking.cluster === "pythtest-conformance" && ( - - test - - )} -
- ), - uptimeScore: ranking.uptime_score, - deviationPenalty: ranking.deviation_penalty, - deviationScore: ranking.deviation_score, - stalledPenalty: ranking.stalled_penalty, - stalledScore: ranking.stalled_score, - }))} - nameLoadingSkeleton={} - /> - {children} - - ) : ( - notFound() - ); -}; diff --git a/apps/insights/src/components/PriceFeed/publishers-card.tsx b/apps/insights/src/components/PriceFeed/publishers-card.tsx new file mode 100644 index 0000000000..9a24c8267e --- /dev/null +++ b/apps/insights/src/components/PriceFeed/publishers-card.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Switch } from "@pythnetwork/component-library/Switch"; +import { type ComponentProps, useState, useMemo } from "react"; + +import { PriceComponentsCard } from "../PriceComponentsCard"; + +type OwnProps = { + priceComponents: (ComponentProps< + typeof PriceComponentsCard + >["priceComponents"][number] & { + isTest: boolean; + })[]; +}; + +type Props = Omit, keyof OwnProps> & + OwnProps; + +export const PublishersCard = ({ priceComponents, ...props }: Props) => { + const [includeTestComponents, setIncludeTestComponents] = useState(false); + + const filteredPriceComponents = useMemo( + () => + includeTestComponents + ? priceComponents + : priceComponents.filter((component) => !component.isTest), + [includeTestComponents, priceComponents], + ); + + return ( + + Show test components + + } + {...props} + /> + ); +}; diff --git a/apps/insights/src/components/PriceFeed/price-components.module.scss b/apps/insights/src/components/PriceFeed/publishers.module.scss similarity index 100% rename from apps/insights/src/components/PriceFeed/price-components.module.scss rename to apps/insights/src/components/PriceFeed/publishers.module.scss diff --git a/apps/insights/src/components/PriceFeed/publishers.tsx b/apps/insights/src/components/PriceFeed/publishers.tsx new file mode 100644 index 0000000000..590983f109 --- /dev/null +++ b/apps/insights/src/components/PriceFeed/publishers.tsx @@ -0,0 +1,53 @@ +import { Badge } from "@pythnetwork/component-library/Badge"; +import { lookup as lookupPublisher } from "@pythnetwork/known-publishers"; +import { notFound } from "next/navigation"; + +import { PublishersCard } from "./publishers-card"; +import styles from "./publishers.module.scss"; +import { getRankings } from "../../services/clickhouse"; +import { getData } from "../../services/pyth"; +import { PublisherTag } from "../PublisherTag"; + +type Props = { + params: Promise<{ + slug: string; + }>; +}; + +export const Publishers = async ({ params }: Props) => { + const { slug } = await params; + const symbol = decodeURIComponent(slug); + const [data, rankings] = await Promise.all([getData(), getRankings(symbol)]); + const feed = data.find((feed) => feed.symbol === symbol); + + return feed ? ( + ({ + id: ranking.publisher, + nameAsString: lookupPublisher(ranking.publisher)?.name, + score: ranking.final_score, + isTest: ranking.cluster === "pythtest-conformance", + name: ( +
+ + {ranking.cluster === "pythtest-conformance" && ( + + test + + )} +
+ ), + uptimeScore: ranking.uptime_score, + deviationPenalty: ranking.deviation_penalty, + deviationScore: ranking.deviation_score, + stalledPenalty: ranking.stalled_penalty, + stalledScore: ranking.stalled_score, + }))} + nameLoadingSkeleton={} + /> + ) : ( + notFound() + ); +}; diff --git a/apps/insights/src/components/PriceFeed/reference-data.tsx b/apps/insights/src/components/PriceFeed/reference-data.tsx index e33f6a67f9..bb0b51f7f4 100644 --- a/apps/insights/src/components/PriceFeed/reference-data.tsx +++ b/apps/insights/src/components/PriceFeed/reference-data.tsx @@ -107,6 +107,7 @@ export const ReferenceData = ({ feed }: Props) => { {children} Asset Classes @@ -109,6 +110,7 @@ const AssetClassTable = ({ return (
{
{ Show all diff --git a/apps/insights/src/components/PriceFeeds/price-feeds-card.tsx b/apps/insights/src/components/PriceFeeds/price-feeds-card.tsx index 95cfc28204..f2878e8903 100644 --- a/apps/insights/src/components/PriceFeeds/price-feeds-card.tsx +++ b/apps/insights/src/components/PriceFeeds/price-feeds-card.tsx @@ -19,6 +19,7 @@ import { useFilter, useCollator } from "react-aria"; import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination"; import { SKELETON_WIDTH } from "../LivePrices"; import { NoResults } from "../NoResults"; +import rootStyles from "../Root/index.module.scss"; type Props = { id: string; @@ -248,6 +249,7 @@ const PriceFeedsCardContents = ({ rounded fill label="Price Feeds" + stickyHeader={rootStyles.headerHeight} columns={[ { id: "priceFeedName", diff --git a/apps/insights/src/components/Publisher/active-feeds-card.tsx b/apps/insights/src/components/Publisher/active-feeds-card.tsx new file mode 100644 index 0000000000..42b3a9c4ca --- /dev/null +++ b/apps/insights/src/components/Publisher/active-feeds-card.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { StatCard } from "@pythnetwork/component-library/StatCard"; +import { useSelectedLayoutSegment } from "next/navigation"; + +import { FormattedNumber } from "../FormattedNumber"; +import { Meter } from "../Meter"; + +type Props = { + publisherKey: string; + activeFeeds: number; + totalFeeds: number; +}; + +export const ActiveFeedsCard = ({ + publisherKey, + activeFeeds, + totalFeeds, +}: Props) => { + const layoutSegment = useSelectedLayoutSegment(); + + return ( + + + % + + } + miniStat2={ + <> + + % + + } + {...(layoutSegment !== "price-feeds" && { + href: `/publishers/${publisherKey}/price-feeds`, + })} + > + + + ); +}; diff --git a/apps/insights/src/components/Publisher/chart-card.tsx b/apps/insights/src/components/Publisher/chart-card.tsx new file mode 100644 index 0000000000..352b4a70a1 --- /dev/null +++ b/apps/insights/src/components/Publisher/chart-card.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { StatCard } from "@pythnetwork/component-library/StatCard"; +import dynamic from "next/dynamic"; +import { + type ElementType, + type ComponentProps, + type ReactNode, + Suspense, + useState, + useMemo, + useCallback, +} from "react"; +import { ResponsiveContainer, Tooltip, Line, XAxis, YAxis } from "recharts"; +import type { CategoricalChartState } from "recharts/types/chart/types"; + +const LineChart = dynamic( + () => import("recharts").then((recharts) => recharts.LineChart), + { + ssr: false, + }, +); + +const CHART_HEIGHT = 36; + +type OwnProps = { + chartClassName?: string | undefined; + lineClassName?: string | undefined; + data: Point[]; +}; + +type Point = { + x: T; + y: number; + displayX?: ReactNode | undefined; + displayY?: ReactNode | undefined; +}; + +type Props = Omit< + ComponentProps>, + keyof OwnProps | "children" +> & + OwnProps; + +export const ChartCard = ({ + chartClassName, + lineClassName, + data, + stat, + miniStat, + ...props +}: Props) => { + const [selectedPoint, setSelectedPoint] = useState>( + undefined, + ); + const selectedDate = useMemo( + () => + selectedPoint ? (selectedPoint.displayX ?? selectedPoint.x) : undefined, + [selectedPoint], + ); + const domain = useMemo( + () => [ + Math.min(...data.map((point) => point.y)), + Math.max(...data.map((point) => point.y)), + ], + [data], + ); + const updateSelectedPoint = useCallback( + (chart: CategoricalChartState) => { + setSelectedPoint( + (chart.activePayload as { payload: Point }[] | undefined)?.[0] + ?.payload, + ); + }, + [setSelectedPoint], + ); + + return ( + + } + > + + + <>} /> + + + + + + + + ); +}; diff --git a/apps/insights/src/components/Publisher/get-rankings-with-data.ts b/apps/insights/src/components/Publisher/get-rankings-with-data.ts new file mode 100644 index 0000000000..003720b275 --- /dev/null +++ b/apps/insights/src/components/Publisher/get-rankings-with-data.ts @@ -0,0 +1,24 @@ +import { getPublisherFeeds } from "../../services/clickhouse"; +import { getData } from "../../services/pyth"; + +export const getRankingsWithData = async (key: string) => { + const [data, rankings] = await Promise.all([ + getData(), + getPublisherFeeds(key), + ]); + const rankingsWithData = rankings.map((ranking) => { + const feed = data.find((feed) => feed.symbol === ranking.symbol); + if (!feed) { + throw new NoSuchFeedError(ranking.symbol); + } + return { ranking, feed }; + }); + return rankingsWithData; +}; + +class NoSuchFeedError extends Error { + constructor(symbol: string) { + super(`No feed exists named ${symbol}`); + this.name = "NoSuchFeedError"; + } +} diff --git a/apps/insights/src/components/Publisher/layout.module.scss b/apps/insights/src/components/Publisher/layout.module.scss new file mode 100644 index 0000000000..67f56604e5 --- /dev/null +++ b/apps/insights/src/components/Publisher/layout.module.scss @@ -0,0 +1,136 @@ +@use "@pythnetwork/component-library/theme"; + +.publisherLayout { + .header { + @include theme.max-width; + + margin-bottom: theme.spacing(6); + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(8); + + .headerRow, + .rightGroup, + .stats { + display: flex; + flex-flow: row nowrap; + } + + .headerRow, + .rightGroup { + align-items: center; + } + + .stats { + align-items: stretch; + gap: theme.spacing(6); + + & > * { + flex: 1 1 0px; + width: 0; + } + + .medianScoreChart svg { + cursor: pointer; + } + + .publisherRankingExplainButton { + margin-top: -#{theme.button-padding("xs", false)}; + margin-right: -#{theme.button-padding("xs", false)}; + } + + .primarySparkChartLine { + color: theme.color("chart", "series", "primary"); + } + + .secondarySparkChartLine { + color: theme.color("chart", "series", "neutral"); + } + + .activeDate { + color: theme.color("muted"); + } + + .tokens { + display: flex; + flex-flow: row nowrap; + align-items: center; + } + + .oisAllocation[data-is-overallocated] { + color: theme.color("states", "error", "base"); + } + } + + .headerRow { + justify-content: space-between; + } + + .rightGroup { + gap: theme.spacing(2); + } + + .breadcrumbs { + margin-bottom: -#{theme.spacing(2)}; + } + } + + .priceFeedsTabLabel { + display: inline-flex; + flex-flow: row nowrap; + gap: theme.spacing(2); + align-items: center; + } + + .body { + @include theme.max-width; + + padding-top: theme.spacing(6); + } +} + +.publisherRankingExplainDescription { + margin: 0; + + b { + font-weight: theme.font-weight("semibold"); + } +} + +.oisDrawer { + .oisDrawerBody { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(4, max-content); + gap: theme.spacing(4); + + .oisMeter { + grid-column: span 2 / span 2; + margin-bottom: -#{theme.spacing(12)}; + + .oisMeterIcon { + font-size: theme.spacing(6); + margin-bottom: theme.spacing(2); + } + + .oisMeterLabel { + color: theme.color("heading"); + + @include theme.text("xl", "medium"); + } + } + } + + .oisDrawerFooter { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: center; + } +} + +.medianScoreDrawerBody { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(6); +} diff --git a/apps/insights/src/components/Publisher/layout.tsx b/apps/insights/src/components/Publisher/layout.tsx new file mode 100644 index 0000000000..8d09303c84 --- /dev/null +++ b/apps/insights/src/components/Publisher/layout.tsx @@ -0,0 +1,403 @@ +import { BookOpenText } from "@phosphor-icons/react/dist/ssr/BookOpenText"; +import { Browsers } from "@phosphor-icons/react/dist/ssr/Browsers"; +import { Info } from "@phosphor-icons/react/dist/ssr/Info"; +import { Lightbulb } from "@phosphor-icons/react/dist/ssr/Lightbulb"; +import { Ranking } from "@phosphor-icons/react/dist/ssr/Ranking"; +import { ShieldChevron } from "@phosphor-icons/react/dist/ssr/ShieldChevron"; +import { Alert, AlertTrigger } from "@pythnetwork/component-library/Alert"; +import { Badge } from "@pythnetwork/component-library/Badge"; +import { Breadcrumbs } from "@pythnetwork/component-library/Breadcrumbs"; +import { Button } from "@pythnetwork/component-library/Button"; +import { DrawerTrigger, Drawer } from "@pythnetwork/component-library/Drawer"; +import { InfoBox } from "@pythnetwork/component-library/InfoBox"; +import { StatCard } from "@pythnetwork/component-library/StatCard"; +import { notFound } from "next/navigation"; +import type { ReactNode } from "react"; + +import { ActiveFeedsCard } from "./active-feeds-card"; +import { ChartCard } from "./chart-card"; +import styles from "./layout.module.scss"; +import { MedianScoreHistory } from "./median-score-history"; +import { OisApyHistory } from "./ois-apy-history"; +import { + getPublishers, + getPublisherFeeds, + getPublisherRankingHistory, + getPublisherMedianScoreHistory, +} from "../../services/clickhouse"; +import { getPublisherCaps } from "../../services/hermes"; +import { getTotalFeedCount } from "../../services/pyth"; +import { getPublisherPoolData } from "../../services/staking"; +import { ChangeValue } from "../ChangeValue"; +import { FormattedDate } from "../FormattedDate"; +import { FormattedNumber } from "../FormattedNumber"; +import { FormattedTokens } from "../FormattedTokens"; +import { Meter } from "../Meter"; +import { PublisherKey } from "../PublisherKey"; +import { PublisherTag } from "../PublisherTag"; +import { SemicircleMeter } from "../SemicircleMeter"; +import { TabPanel, TabRoot, Tabs } from "../Tabs"; +import { TokenIcon } from "../TokenIcon"; + +type Props = { + children: ReactNode; + params: Promise<{ + key: string; + }>; +}; + +export const PublishersLayout = async ({ children, params }: Props) => { + const { key } = await params; + const [ + publishers, + rankingHistory, + medianScoreHistory, + totalFeedsCount, + oisStats, + publisherFeeds, + ] = await Promise.all([ + getPublishers(), + getPublisherRankingHistory(key), + getPublisherMedianScoreHistory(key), + getTotalFeedCount(), + getOisStats(key), + getPublisherFeeds(key), + ]); + + const publisher = publishers.find((publisher) => publisher.key === key); + + const currentRanking = rankingHistory.at(-1); + const previousRanking = rankingHistory.at(-2); + + const currentMedianScore = medianScoreHistory.at(-1); + const previousMedianScore = medianScoreHistory.at(-2); + + return currentRanking && currentMedianScore && publisher ? ( +
+
+
+ }, + ]} + /> +
+
+ +
+
+ + + }> +

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

+
+ + } + data={rankingHistory.map(({ timestamp, rank }) => ({ + x: timestamp, + y: rank, + displayX: ( + + + + ), + }))} + stat={currentRanking.rank} + {...(previousRanking && { + miniStat: ( + + {Math.abs(currentRanking.rank - previousRanking.rank)} + + ), + })} + /> + + } + data={medianScoreHistory.map(({ time, medianScore }) => ({ + x: time, + y: medianScore, + displayX: ( + + + + ), + displayY: ( + + ), + }))} + stat={ + + } + {...(previousMedianScore && { + miniStat: ( + + + % + + ), + })} + /> + + Documentation + + } + > + + } header="Publisher Score"> + Each price feed a publisher provides has an associated score, + which is determined by the component{"'"}s uptime, price + deviation, and staleness. This panel shows the median for each + score across all price feeds published by this publisher, as + well as the overall median score across all those feeds. + + + + + + oisStats.maxPoolSize + ? "" + : undefined + } + > + + % + + } + corner={} + > + + + + + + + } + endLabel={ + + + + + + + } + /> + + + + + + } + > + + +
OIS Pool
+
+ + + + + } + /> + + + + + } + /> + + } + header="Oracle Integrity Staking (OIS)" + > + OIS allows anyone to help secure Pyth and protect DeFi. Through + decentralized staking rewards and slashing, OIS incentivizes + Pyth publishers to maintain high-quality data contributions. + PYTH holders can stake to publishers to further reinforce oracle + security. Rewards are programmatically distributed to high + quality publishers and the stakers supporting them to strengthen + oracle integrity. + +
+
+
+
+ + + Price Feeds + + {publisherFeeds.length} + +
+ ), + }, + ]} + /> + {children} + + + ) : ( + notFound() + ); +}; + +const getChangeDirection = (previousValue: number, currentValue: number) => { + if (currentValue < previousValue) { + return "down"; + } else if (currentValue > previousValue) { + return "up"; + } else { + return "flat"; + } +}; + +const getOisStats = async (key: string) => { + const [publisherPoolData, publisherCaps] = await Promise.all([ + getPublisherPoolData(), + getPublisherCaps(), + ]); + + const publisher = publisherPoolData.find( + (publisher) => publisher.pubkey === key, + ); + + return { + apyHistory: publisher?.apyHistory, + poolUtilization: + (publisher?.totalDelegation ?? 0n) + + (publisher?.totalDelegationDelta ?? 0n), + maxPoolSize: + publisherCaps.parsed?.[0]?.publisher_stake_caps.find( + ({ publisher }) => publisher === key, + )?.cap ?? 0, + }; +}; diff --git a/apps/insights/src/components/Publisher/median-score-history.module.scss b/apps/insights/src/components/Publisher/median-score-history.module.scss new file mode 100644 index 0000000000..72d0baa7bd --- /dev/null +++ b/apps/insights/src/components/Publisher/median-score-history.module.scss @@ -0,0 +1,177 @@ +@use "@pythnetwork/component-library/theme"; + +.medianScoreHistory { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(6); + + .medianScoreHistoryChart { + grid-column: span 2 / span 2; + border-radius: theme.border-radius("2xl"); + border: 1px solid theme.color("border"); + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(4); + + .top { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: flex-start; + margin: theme.spacing(4); + + .left { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(1); + + .header { + color: theme.color("heading"); + + @include theme.text("sm", "medium"); + } + + .subheader { + color: theme.color("muted"); + + @include theme.text("xs", "normal"); + } + } + } + + .chart { + border-bottom-left-radius: theme.border-radius("2xl"); + border-bottom-right-radius: theme.border-radius("2xl"); + overflow: hidden; + + .medianScore, + .medianUptimeScore, + .medianDeviationScore, + .medianStalledScore { + transition: opacity 100ms linear; + opacity: 0.1; + } + + .medianScore { + color: theme.color("states", "data", "normal"); + } + + .medianUptimeScore { + color: theme.color("states", "info", "normal"); + } + + .medianDeviationScore { + color: theme.color("states", "lime", "normal"); + } + + .medianStalledScore { + color: theme.color("states", "warning", "normal"); + } + } + + &:not([data-focused-score], [data-hovered-score]) { + .medianScore, + .medianUptimeScore, + .medianDeviationScore, + .medianStalledScore { + opacity: 1; + } + } + + &[data-hovered-score="uptime"] { + .medianUptimeScore { + opacity: 0.7; + } + } + + &[data-focused-score="uptime"] { + .medianUptimeScore { + opacity: 1; + } + } + + &[data-hovered-score="deviation"] { + .medianDeviationScore { + opacity: 0.7; + } + } + + &[data-focused-score="deviation"] { + .medianDeviationScore { + opacity: 1; + } + } + + &[data-hovered-score="stalled"] { + .medianStalledScore { + opacity: 0.7; + } + } + + &[data-focused-score="stalled"] { + .medianStalledScore { + opacity: 1; + } + } + + &[data-hovered-score="final"] { + .medianScore { + opacity: 0.7; + } + } + + &[data-focused-score="final"] { + .medianScore { + opacity: 1; + } + } + } + + .rankingBreakdown { + .legendCell, + .scoreCell { + vertical-align: top; + } + + .uptimeLegend, + .deviationLegend, + .stalledLegend, + .finalScoreLegend { + width: theme.spacing(4); + height: theme.spacing(4); + border-radius: theme.border-radius("full"); + } + + .uptimeLegend { + background: theme.color("states", "info", "normal"); + } + + .deviationLegend { + background: theme.color("states", "lime", "normal"); + } + + .stalledLegend { + background: theme.color("states", "warning", "normal"); + } + + .finalScoreLegend { + background: theme.color("states", "data", "normal"); + } + + .metric { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(2); + overflow: hidden; + + .metricDescription { + color: theme.color("muted"); + + @include theme.text("sm", "normal"); + + white-space: normal; + line-height: 1.2; + } + } + } +} diff --git a/apps/insights/src/components/Publisher/median-score-history.tsx b/apps/insights/src/components/Publisher/median-score-history.tsx new file mode 100644 index 0000000000..2ac0edabba --- /dev/null +++ b/apps/insights/src/components/Publisher/median-score-history.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { Card } from "@pythnetwork/component-library/Card"; +import { Table } from "@pythnetwork/component-library/Table"; +import dynamic from "next/dynamic"; +import { Suspense, useState, useCallback, useMemo } from "react"; +import { useDateFormatter, useNumberFormatter } from "react-aria"; +import { ResponsiveContainer, Tooltip, Line, XAxis, YAxis } from "recharts"; +import type { CategoricalChartState } from "recharts/types/chart/types"; + +import styles from "./median-score-history.module.scss"; +import { Score } from "../Score"; + +const LineChart = dynamic( + () => import("recharts").then((recharts) => recharts.LineChart), + { + ssr: false, + }, +); + +const CHART_HEIGHT = 104; + +type Props = { + medianScoreHistory: Point[]; +}; + +type Point = { + time: Date; + medianScore: number; + medianUptimeScore: number; + medianDeviationScore: number; + medianStalledScore: number; +}; + +export const MedianScoreHistory = ({ medianScoreHistory }: Props) => { + const [selectedPoint, setSelectedPoint] = useState< + (typeof medianScoreHistory)[number] | undefined + >(undefined); + const updateSelectedPoint = useCallback( + (chart: CategoricalChartState) => { + setSelectedPoint( + (chart.activePayload as { payload: Point }[] | undefined)?.[0]?.payload, + ); + }, + [setSelectedPoint], + ); + const currentPoint = useMemo( + () => selectedPoint ?? medianScoreHistory.at(-1), + [selectedPoint, medianScoreHistory], + ); + const dateFormatter = useDateFormatter(); + const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 4 }); + + const [hoveredScore, setHoveredScore] = useState(undefined); + const hoverUptime = useCallback(() => { + setHoveredScore("uptime"); + }, [setHoveredScore]); + const hoverDeviation = useCallback(() => { + setHoveredScore("deviation"); + }, [setHoveredScore]); + const hoverStalled = useCallback(() => { + setHoveredScore("stalled"); + }, [setHoveredScore]); + const hoverFinal = useCallback(() => { + setHoveredScore("final"); + }, [setHoveredScore]); + const clearHover = useCallback(() => { + setHoveredScore(undefined); + }, [setHoveredScore]); + + const [focusedScore, setFocusedScore] = useState(undefined); + const toggleFocusedScore = useCallback( + (value: typeof focusedScore) => { + setFocusedScore((cur) => (cur === value ? undefined : value)); + }, + [setFocusedScore], + ); + const toggleFocusUptime = useCallback(() => { + toggleFocusedScore("uptime"); + }, [toggleFocusedScore]); + const toggleFocusDeviation = useCallback(() => { + toggleFocusedScore("deviation"); + }, [toggleFocusedScore]); + const toggleFocusStalled = useCallback(() => { + toggleFocusedScore("stalled"); + }, [toggleFocusedScore]); + const toggleFocusFinal = useCallback(() => { + toggleFocusedScore("final"); + }, [toggleFocusedScore]); + + return ( +
+
+
+
+

Score History

+
+ {selectedPoint + ? dateFormatter.format(selectedPoint.time) + : "Last 30 days"} +
+
+ {currentPoint && ( + + )} +
+ } + > + + + <>} /> + + + + + + + + + +
+ +
, + metric: ( + + ), + score: numberFormatter.format( + currentPoint?.medianUptimeScore ?? 0, + ), + }, + }, + { + id: "deviation", + onHoverStart: hoverDeviation, + onHoverEnd: clearHover, + onAction: toggleFocusDeviation, + data: { + legend:
, + metric: ( + + ), + score: numberFormatter.format( + currentPoint?.medianDeviationScore ?? 0, + ), + }, + }, + { + id: "staleness", + onHoverStart: hoverStalled, + onHoverEnd: clearHover, + onAction: toggleFocusStalled, + data: { + legend:
, + metric: ( + + ), + score: numberFormatter.format( + currentPoint?.medianStalledScore ?? 0, + ), + }, + }, + { + id: "final", + onHoverStart: hoverFinal, + onHoverEnd: clearHover, + onAction: toggleFocusFinal, + data: { + legend:
, + metric: ( + + ), + score: numberFormatter.format(currentPoint?.medianScore ?? 0), + }, + }, + ]} + /> + +
+ ); +}; + +type FocusedScore = "uptime" | "deviation" | "stalled" | "final" | undefined; + +type CurrentValueProps = { + point: Point; + focusedScore: FocusedScore; +}; + +const CurrentValue = ({ point, focusedScore }: CurrentValueProps) => { + const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 4 }); + switch (focusedScore) { + case "uptime": { + return numberFormatter.format(point.medianUptimeScore); + } + case "deviation": { + return numberFormatter.format(point.medianDeviationScore); + } + case "stalled": { + return numberFormatter.format(point.medianStalledScore); + } + default: { + return ; + } + } +}; + +type MetricProps = { + name: string; + description: string; +}; + +const Metric = ({ name, description }: MetricProps) => ( +
+
{name}
+
{description}
+
+); diff --git a/apps/insights/src/components/Publisher/ois-apy-history.module.scss b/apps/insights/src/components/Publisher/ois-apy-history.module.scss new file mode 100644 index 0000000000..d5721309da --- /dev/null +++ b/apps/insights/src/components/Publisher/ois-apy-history.module.scss @@ -0,0 +1,56 @@ +@use "@pythnetwork/component-library/theme"; + +.oisApyHistory { + grid-column: span 2 / span 2; + border-radius: theme.border-radius("2xl"); + padding-top: theme.spacing(4); + border: 1px solid theme.color("border"); + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(3); + + .oisApyHistoryHeader { + color: theme.color("muted"); + + @include theme.text("sm", "medium"); + + margin: 0 theme.spacing(4); + } + + .currentPoint { + margin: 0 theme.spacing(4); + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: space-between; + + .apy { + color: theme.color("heading"); + + @include theme.text("2xl", "medium"); + } + + .date { + color: theme.color("muted"); + + @include theme.text("sm", "normal"); + } + } + + .chart { + border-bottom-left-radius: theme.border-radius("2xl"); + border-bottom-right-radius: theme.border-radius("2xl"); + overflow: hidden; + + .chartArea { + color: theme.color("button", "primary", "background", "normal"); + + :global { + // stylelint-disable-next-line selector-class-pattern + .recharts-area-area { + fill: theme.color("states", "data", "background"); + } + } + } + } +} diff --git a/apps/insights/src/components/Publisher/ois-apy-history.tsx b/apps/insights/src/components/Publisher/ois-apy-history.tsx new file mode 100644 index 0000000000..6f1c919a6c --- /dev/null +++ b/apps/insights/src/components/Publisher/ois-apy-history.tsx @@ -0,0 +1,88 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { Suspense, useState, useCallback, useMemo } from "react"; +import { useDateFormatter, useNumberFormatter } from "react-aria"; +import { ResponsiveContainer, Tooltip, Area, XAxis, YAxis } from "recharts"; +import type { CategoricalChartState } from "recharts/types/chart/types"; + +import styles from "./ois-apy-history.module.scss"; + +const AreaChart = dynamic( + () => import("recharts").then((recharts) => recharts.AreaChart), + { + ssr: false, + }, +); + +const CHART_HEIGHT = 104; + +type Props = { + apyHistory: Point[]; +}; + +type Point = { + date: Date; + apy: number; +}; + +export const OisApyHistory = ({ apyHistory }: Props) => { + const [selectedPoint, setSelectedPoint] = useState< + (typeof apyHistory)[number] | undefined + >(undefined); + const updateSelectedPoint = useCallback( + (chart: CategoricalChartState) => { + setSelectedPoint( + (chart.activePayload as { payload: Point }[] | undefined)?.[0]?.payload, + ); + }, + [setSelectedPoint], + ); + const currentPoint = useMemo( + () => selectedPoint ?? apyHistory.at(-1), + [selectedPoint, apyHistory], + ); + const dateFormatter = useDateFormatter(); + const numberFormatter = useNumberFormatter({ maximumFractionDigits: 2 }); + + return ( +
+

APY History

+ {currentPoint && ( +
+ + {numberFormatter.format(currentPoint.apy)}% + + + {dateFormatter.format(currentPoint.date)} + +
+ )} + } + > + + + <>} /> + + + + + + +
+ ); +}; diff --git a/apps/insights/src/components/Publisher/performance.module.scss b/apps/insights/src/components/Publisher/performance.module.scss new file mode 100644 index 0000000000..e5e5fd6bad --- /dev/null +++ b/apps/insights/src/components/Publisher/performance.module.scss @@ -0,0 +1,12 @@ +@use "@pythnetwork/component-library/theme"; + +.performance { + display: grid; + grid-template-columns: 1fr 1fr; + gap: theme.spacing(12) theme.spacing(6); + align-items: flex-start; + + > *:first-child { + grid-column: span 2 / span 2; + } +} diff --git a/apps/insights/src/components/Publisher/performance.tsx b/apps/insights/src/components/Publisher/performance.tsx new file mode 100644 index 0000000000..1f78b6914b --- /dev/null +++ b/apps/insights/src/components/Publisher/performance.tsx @@ -0,0 +1,188 @@ +import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast"; +import { Network } from "@phosphor-icons/react/dist/ssr/Network"; +import { Badge } from "@pythnetwork/component-library/Badge"; +import { Card } from "@pythnetwork/component-library/Card"; +import { Table } from "@pythnetwork/component-library/Table"; +import { notFound } from "next/navigation"; + +import { getRankingsWithData } from "./get-rankings-with-data"; +import styles from "./performance.module.scss"; +import { getPublishers } from "../../services/clickhouse"; +import { getTotalFeedCount } from "../../services/pyth"; +import { PriceFeedTag } from "../PriceFeedTag"; +import { PublisherTag } from "../PublisherTag"; +import { Ranking } from "../Ranking"; +import { Score } from "../Score"; + +const PUBLISHER_SCORE_WIDTH = 24; + +type Props = { + params: Promise<{ + key: string; + }>; +}; + +export const Performance = async ({ params }: Props) => { + const { key } = await params; + const [publishers, rankingsWithData, totalFeeds] = await Promise.all([ + getPublishers(), + getRankingsWithData(key), + getTotalFeedCount(), + ]); + const slicedPublishers = sliceAround( + publishers, + (publisher) => publisher.key === key, + 2, + ); + + return slicedPublishers === undefined ? ( + notFound() + ) : ( +
+ } title="Publishers Ranking"> +
({ + id: publisher.key, + data: { + ranking: ( + + {publisher.rank} + + ), + activeFeeds: publisher.numSymbols, + inactiveFeeds: totalFeeds - publisher.numSymbols, + medianScore: ( + + ), + name: , + }, + ...(publisher.key !== key && { + href: `/publishers/${publisher.key}`, + }), + }))} + /> + + } title="High-performing feeds"> +
ranking.final_score > 0.9) + .sort((a, b) => b.ranking.final_score - a.ranking.final_score), + )} + /> + + } title="Low-performing feeds"> +
ranking.final_score < 0.7) + .sort((a, b) => a.ranking.final_score - b.ranking.final_score), + )} + /> + + + ); +}; + +const feedColumns = [ + { + id: "score" as const, + name: "SCORE", + alignment: "left" as const, + width: 40, + }, + { + id: "asset" as const, + name: "ASSET", + isRowHeader: true, + alignment: "left" as const, + fill: true, + }, + { + id: "assetClass" as const, + name: "ASSET CLASS", + alignment: "right" as const, + width: 50, + }, +]; + +const getFeedRows = ( + rankingsWithData: Awaited>, +) => + rankingsWithData.slice(0, 10).map(({ feed, ranking }) => ({ + id: ranking.symbol, + data: { + asset: , + assetClass: ( + + {feed.product.asset_type.toUpperCase()} + + ), + score: ( + + ), + }, + })); + +const sliceAround = ( + arr: T[], + predicate: (elem: T) => boolean, + count: number, +): T[] | undefined => { + const index = arr.findIndex((item) => predicate(item)); + if (index === -1) { + return undefined; + } else { + const min = Math.max( + 0, + index - count - Math.max(0, index + count + 1 - arr.length), + ); + const max = Math.min(arr.length, min + count * 2 + 1); + return arr.slice(min, max); + } +}; diff --git a/apps/insights/src/components/Publisher/price-feeds.tsx b/apps/insights/src/components/Publisher/price-feeds.tsx new file mode 100644 index 0000000000..6825a0cb40 --- /dev/null +++ b/apps/insights/src/components/Publisher/price-feeds.tsx @@ -0,0 +1,32 @@ +import { getRankingsWithData } from "./get-rankings-with-data"; +import { PriceComponentsCard } from "../PriceComponentsCard"; +import { PriceFeedTag } from "../PriceFeedTag"; + +type Props = { + params: Promise<{ + key: string; + }>; +}; + +export const PriceFeeds = async ({ params }: Props) => { + const { key } = await params; + const rankingsWithData = await getRankingsWithData(key); + + return ( + ({ + id: feed.product.price_account, + nameAsString: feed.product.display_symbol, + score: ranking.final_score, + name: , + uptimeScore: ranking.uptime_score, + deviationPenalty: ranking.deviation_penalty, + deviationScore: ranking.deviation_score, + stalledPenalty: ranking.stalled_penalty, + stalledScore: ranking.stalled_score, + }))} + nameLoadingSkeleton={} + /> + ); +}; diff --git a/apps/insights/src/components/PublisherKey/index.module.scss b/apps/insights/src/components/PublisherKey/index.module.scss new file mode 100644 index 0000000000..aaa125b0a7 --- /dev/null +++ b/apps/insights/src/components/PublisherKey/index.module.scss @@ -0,0 +1,9 @@ +@use "@pythnetwork/component-library/theme"; + +.publisherKey { + @each $size, $values in theme.$button-sizes { + &[data-size="#{$size}"] { + margin: 0 -#{theme.button-padding($size, true)}; + } + } +} diff --git a/apps/insights/src/components/PublisherKey/index.tsx b/apps/insights/src/components/PublisherKey/index.tsx new file mode 100644 index 0000000000..ac6642d636 --- /dev/null +++ b/apps/insights/src/components/PublisherKey/index.tsx @@ -0,0 +1,27 @@ +import clsx from "clsx"; +import type { ComponentProps } from "react"; + +import styles from "./index.module.scss"; +import { CopyButton } from "../CopyButton"; + +type KeyProps = Omit< + ComponentProps, + "variant" | "text" | "children" +> & { + publisherKey: string; +}; + +export const PublisherKey = ({ + publisherKey, + className, + ...props +}: KeyProps) => ( + + {`${publisherKey.slice(0, 4)}...${publisherKey.slice(-4)}`} + +); diff --git a/apps/insights/src/components/PublisherTag/index.module.scss b/apps/insights/src/components/PublisherTag/index.module.scss index b4f98f782f..466ed39fc4 100644 --- a/apps/insights/src/components/PublisherTag/index.module.scss +++ b/apps/insights/src/components/PublisherTag/index.module.scss @@ -11,10 +11,6 @@ height: theme.spacing(9); } - .key { - margin: 0 -#{theme.button-padding("sm", true)}; - } - .nameAndKey { display: flex; flex-flow: column nowrap; @@ -26,7 +22,6 @@ } .key { - margin: -#{theme.spacing(1)} -#{theme.button-padding("xs", true)}; margin-bottom: -#{theme.spacing(2)}; } } diff --git a/apps/insights/src/components/PublisherTag/index.tsx b/apps/insights/src/components/PublisherTag/index.tsx index 5e4492d352..5e6539deb1 100644 --- a/apps/insights/src/components/PublisherTag/index.tsx +++ b/apps/insights/src/components/PublisherTag/index.tsx @@ -5,7 +5,7 @@ import clsx from "clsx"; import { type ComponentProps, useMemo } from "react"; import styles from "./index.module.scss"; -import { CopyButton } from "../CopyButton"; +import { PublisherKey } from "../PublisherKey"; type Props = { isLoading: true } | { isLoading?: false; publisherKey: string }; @@ -32,24 +32,14 @@ export const PublisherTag = (props: Props) => { {knownPublisher ? (
{knownPublisher.name}
- - {`${props.publisherKey.slice(0, 4)}...${props.publisherKey.slice(-4)}`} - + publisherKey={props.publisherKey} + size="xs" + />
) : ( - - {`${props.publisherKey.slice(0, 4)}...${props.publisherKey.slice(-4)}`} - + )} )} diff --git a/apps/insights/src/components/Publishers/index.module.scss b/apps/insights/src/components/Publishers/index.module.scss index 84b82bd78b..a7568592a6 100644 --- a/apps/insights/src/components/Publishers/index.module.scss +++ b/apps/insights/src/components/Publishers/index.module.scss @@ -36,56 +36,25 @@ grid-column: span 2 / span 2; .oisPool { - width: 100%; - height: theme.spacing(72); - overflow: hidden; - display: grid; - place-content: center; - position: relative; - - .oisPoolChart { - position: relative; - top: theme.spacing(8); - - .bar { - fill: theme.color("button", "primary", "background", "normal"); - } - - .background { - fill: theme.color("button", "disabled", "background"); - } + .title { + font-size: theme.font-size("sm"); + font-weight: theme.font-weight("normal"); + color: theme.color("heading"); + margin: 0; } - .legend { - text-align: center; - position: absolute; - top: theme.spacing(30); - display: flex; - width: 100%; - flex-flow: column nowrap; - align-items: center; - gap: theme.spacing(1.5); + .poolUsed { + margin: 0; + color: theme.color("heading"); - .title { - font-size: theme.font-size("sm"); - font-weight: theme.font-weight("normal"); - color: theme.color("heading"); - margin: 0; - } - - .poolUsed { - margin: 0; - color: theme.color("heading"); - - @include theme.h3; - } + @include theme.h3; + } - .poolTotal { - margin: 0; - color: theme.color("muted"); - font-size: theme.font-size("sm"); - font-weight: theme.font-weight("normal"); - } + .poolTotal { + margin: 0; + color: theme.color("muted"); + font-size: theme.font-size("sm"); + font-weight: theme.font-weight("normal"); } } diff --git a/apps/insights/src/components/Publishers/index.tsx b/apps/insights/src/components/Publishers/index.tsx index 6fa8b363c0..1b82922454 100644 --- a/apps/insights/src/components/Publishers/index.tsx +++ b/apps/insights/src/components/Publishers/index.tsx @@ -6,17 +6,20 @@ import { Button } from "@pythnetwork/component-library/Button"; import { Card } from "@pythnetwork/component-library/Card"; import { StatCard } from "@pythnetwork/component-library/StatCard"; import { lookup as lookupPublisher } from "@pythnetwork/known-publishers"; -import { z } from "zod"; import styles from "./index.module.scss"; import { PublishersCard } from "./publishers-card"; -import { SemicircleMeter, Label } from "./semicircle-meter"; -import { client as clickhouseClient } from "../../services/clickhouse"; -import { client as hermesClient } from "../../services/hermes"; -import { CLUSTER, getData } from "../../services/pyth"; -import { client as stakingClient } from "../../services/staking"; +import { getPublishers } from "../../services/clickhouse"; +import { getPublisherCaps } from "../../services/hermes"; +import { getData } from "../../services/pyth"; +import { + getDelState, + getClaimableRewards, + getDistributedRewards, +} from "../../services/staking"; import { FormattedTokens } from "../FormattedTokens"; import { PublisherTag } from "../PublisherTag"; +import { SemicircleMeter, Label } from "../SemicircleMeter"; import { TokenIcon } from "../TokenIcon"; const INITIAL_REWARD_POOL_SIZE = 60_000_000_000_000n; @@ -104,26 +107,21 @@ export const Publishers = async () => { value={Number(oisStats.totalStaked)} maxValue={oisStats.maxPoolSize ?? 0} className={styles.oisPool ?? ""} - chartClassName={styles.oisPoolChart} - barClassName={styles.bar} - backgroundClassName={styles.background} > -
- -

- -

-

- /{" "} - -

-
+ +

+ +

+

+ /{" "} + +

{ ); }; -const getPublishers = async () => { - const rows = await clickhouseClient.query({ - query: - "SELECT key, rank, numSymbols, medianScore FROM insights_publishers(cluster={cluster: String})", - query_params: { cluster: CLUSTER }, - }); - const result = await rows.json(); - - return publishersSchema.parse(result.data); -}; - -const publishersSchema = z.array( - z.strictObject({ - key: z.string(), - rank: z.number(), - numSymbols: z.number(), - medianScore: z.number(), - }), -); - const getTotalFeedCount = async () => { const pythData = await getData(); return pythData.filter(({ price }) => price.numComponentPrices > 0).length; }; const getOisStats = async () => { - const [poolData, rewardCustodyAccount, publisherCaps] = await Promise.all([ - stakingClient.getPoolDataAccount(), - stakingClient.getRewardCustodyAccount(), - hermesClient.getLatestPublisherCaps({ parsed: true }), - ]); + const [delState, claimableRewards, distributedRewards, publisherCaps] = + await Promise.all([ + getDelState(), + getClaimableRewards(), + getDistributedRewards(), + getPublisherCaps(), + ]); return { totalStaked: - sumDelegations(poolData.delState) + sumDelegations(poolData.selfDelState), + sumDelegations(delState.delState) + sumDelegations(delState.selfDelState), rewardsDistributed: - poolData.claimableRewards + - INITIAL_REWARD_POOL_SIZE - - rewardCustodyAccount.amount, + claimableRewards + INITIAL_REWARD_POOL_SIZE - distributedRewards, maxPoolSize: publisherCaps.parsed?.[0]?.publisher_stake_caps .map(({ cap }) => cap) .reduce((acc, value) => acc + value), diff --git a/apps/insights/src/components/Publishers/publishers-card.tsx b/apps/insights/src/components/Publishers/publishers-card.tsx index 12eb0b8a69..cfcd5d343a 100644 --- a/apps/insights/src/components/Publishers/publishers-card.tsx +++ b/apps/insights/src/components/Publishers/publishers-card.tsx @@ -16,6 +16,7 @@ import { useFilter, useCollator } from "react-aria"; import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination"; import { NoResults } from "../NoResults"; import { Ranking } from "../Ranking"; +import rootStyles from "../Root/index.module.scss"; import { Score } from "../Score"; const PUBLISHER_SCORE_WIDTH = 24; @@ -96,7 +97,7 @@ const ResolvedPublishersCard = ({ publishers, ...props }: Props) => { () => paginatedItems.map(({ id, ranking, medianScore, ...data }) => ({ id, - href: "#", + href: `/publishers/${id}`, data: { ...data, ranking: {ranking}, @@ -200,6 +201,7 @@ const PublishersCardContents = ({ rounded fill label="Publishers" + stickyHeader={rootStyles.headerHeight} columns={[ { id: "ranking", diff --git a/apps/insights/src/components/Publishers/semicircle-meter.tsx b/apps/insights/src/components/Publishers/semicircle-meter.tsx deleted file mode 100644 index 081c11de0b..0000000000 --- a/apps/insights/src/components/Publishers/semicircle-meter.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client"; - -import { Meter } from "@pythnetwork/component-library/unstyled/Meter"; -import dynamic from "next/dynamic"; -import { type ComponentProps, Suspense } from "react"; -import { PolarAngleAxis, RadialBar } from "recharts"; - -export { Label } from "@pythnetwork/component-library/unstyled/Label"; - -const RadialBarChart = dynamic( - () => import("recharts").then((recharts) => recharts.RadialBarChart), - { - ssr: false, - }, -); - -type OwnProps = Pick< - ComponentProps, - "width" | "height" -> & { - chartClassName?: string | undefined; - barClassName?: string | undefined; - backgroundClassName?: string | undefined; -}; - -type Props = Omit, keyof OwnProps> & OwnProps; - -export const SemicircleMeter = ({ - width, - height, - chartClassName, - barClassName, - backgroundClassName, - children, - ...props -}: Props) => ( - - {({ percentage }) => ( - <> - - - - {children} - - )} - -); - -type ChartProps = Pick< - Props, - "width" | "height" | "chartClassName" | "backgroundClassName" | "barClassName" -> & { - percentage: number; -}; - -const Chart = ({ - width, - height, - percentage, - chartClassName, - backgroundClassName, - barClassName, -}: ChartProps) => ( - - - - -); diff --git a/apps/insights/src/components/Ranking/index.module.scss b/apps/insights/src/components/Ranking/index.module.scss index af92933b6e..5839d91183 100644 --- a/apps/insights/src/components/Ranking/index.module.scss +++ b/apps/insights/src/components/Ranking/index.module.scss @@ -2,31 +2,31 @@ .ranking { height: theme.spacing(6); - border-radius: theme.border-radius("md"); + border-radius: theme.border-radius("full"); width: 100%; display: inline-block; text-align: center; font-size: theme.font-size("sm"); font-weight: theme.font-weight("medium"); line-height: theme.spacing(6); - color: light-dark( - theme.pallette-color("steel", 800), - theme.pallette-color("steel", 300) - ); .skeleton { width: 100%; height: 100%; - border-radius: theme.border-radius("md"); + border-radius: theme.border-radius("full"); } .content { width: 100%; height: 100%; - border-radius: theme.border-radius("md"); - background: light-dark( - theme.pallette-color("steel", 200), - theme.pallette-color("steel", 700) - ); + border-radius: theme.border-radius("full"); + border: 1px solid theme.color("border"); + color: theme.color("heading"); + } + + &[data-is-current] .content { + color: theme.pallette-color("white"); + background-color: theme.color("button", "primary", "background", "normal"); + border-color: theme.color("button", "primary", "background", "normal"); } } diff --git a/apps/insights/src/components/Ranking/index.tsx b/apps/insights/src/components/Ranking/index.tsx index 20d0be1148..ac6727fe12 100644 --- a/apps/insights/src/components/Ranking/index.tsx +++ b/apps/insights/src/components/Ranking/index.tsx @@ -6,17 +6,23 @@ import styles from "./index.module.scss"; type OwnProps = { isLoading?: boolean | undefined; + isCurrent?: boolean | undefined; }; type Props = Omit, keyof OwnProps> & OwnProps; export const Ranking = ({ isLoading, + isCurrent, className, children, ...props }: Props) => ( - + {isLoading ? ( ) : ( diff --git a/apps/insights/src/components/Root/index.module.scss b/apps/insights/src/components/Root/index.module.scss index 4067b99f9e..cc2565df1f 100644 --- a/apps/insights/src/components/Root/index.module.scss +++ b/apps/insights/src/components/Root/index.module.scss @@ -2,6 +2,11 @@ $header-height: theme.spacing(20); +:export { + // stylelint-disable-next-line property-no-unknown + headerHeight: $header-height; +} + .root { scroll-padding-top: $header-height; overflow-x: hidden; diff --git a/apps/insights/src/components/SemicircleMeter/index.module.scss b/apps/insights/src/components/SemicircleMeter/index.module.scss new file mode 100644 index 0000000000..452efd78b8 --- /dev/null +++ b/apps/insights/src/components/SemicircleMeter/index.module.scss @@ -0,0 +1,34 @@ +@use "@pythnetwork/component-library/theme"; + +.semicircleChart { + width: 100%; + height: calc(var(--height) * (6 / 7)); + overflow: hidden; + display: grid; + place-content: center; + position: relative; + + .chart { + position: relative; + top: theme.spacing(8); + + .bar { + fill: theme.color("button", "primary", "background", "normal"); + } + + .background { + fill: theme.color("button", "disabled", "background"); + } + } + + .legend { + text-align: center; + position: absolute; + top: calc(var(--height) * (2 / 5)); + display: flex; + width: 100%; + flex-flow: column nowrap; + align-items: center; + gap: theme.spacing(1.5); + } +} diff --git a/apps/insights/src/components/SemicircleMeter/index.tsx b/apps/insights/src/components/SemicircleMeter/index.tsx new file mode 100644 index 0000000000..8226ef31ee --- /dev/null +++ b/apps/insights/src/components/SemicircleMeter/index.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Meter } from "@pythnetwork/component-library/unstyled/Meter"; +import clsx from "clsx"; +import dynamic from "next/dynamic"; +import { type ComponentProps, type CSSProperties, Suspense } from "react"; +import { PolarAngleAxis, RadialBar } from "recharts"; + +import styles from "./index.module.scss"; + +export { Label } from "@pythnetwork/component-library/unstyled/Label"; + +const RadialBarChart = dynamic( + () => import("recharts").then((recharts) => recharts.RadialBarChart), + { + ssr: false, + }, +); + +type OwnProps = { + height: number; + width: number; +}; + +type Props = Omit, keyof OwnProps> & OwnProps; + +export const SemicircleMeter = ({ + width, + height, + className, + children, + ...props +}: Props) => ( + + {(...args) => ( + <> + + + + + + +
+ {typeof children === "function" ? children(...args) : children} +
+ + )} +
+); diff --git a/apps/insights/src/components/PriceFeed/tabs.tsx b/apps/insights/src/components/Tabs/index.tsx similarity index 85% rename from apps/insights/src/components/PriceFeed/tabs.tsx rename to apps/insights/src/components/Tabs/index.tsx index 0f509cf7da..c2d37547cd 100644 --- a/apps/insights/src/components/PriceFeed/tabs.tsx +++ b/apps/insights/src/components/Tabs/index.tsx @@ -19,7 +19,7 @@ export const TabRoot = ( }; type TabsProps = Omit, "pathname" | "items"> & { - slug: string; + prefix: string; items: (Omit< ComponentProps["items"], "href" | "id" @@ -28,16 +28,17 @@ type TabsProps = Omit, "pathname" | "items"> & { })[]; }; -export const Tabs = ({ slug, items, ...props }: TabsProps) => { +export const Tabs = ({ prefix, items, ...props }: TabsProps) => { const pathname = usePathname(); - const mappedItems = useMemo(() => { - const prefix = `/price-feeds/${slug}`; - return items.map((item) => ({ - ...item, - id: item.segment ?? "", - href: item.segment ? `${prefix}/${item.segment}` : prefix, - })); - }, [items, slug]); + const mappedItems = useMemo( + () => + items.map((item) => ({ + ...item, + id: item.segment ?? "", + href: item.segment ? `${prefix}/${item.segment}` : prefix, + })), + [items, prefix], + ); return ; }; diff --git a/apps/insights/src/components/TokenIcon/index.module.scss b/apps/insights/src/components/TokenIcon/index.module.scss index dd95d3a4a7..d9fd4d14c7 100644 --- a/apps/insights/src/components/TokenIcon/index.module.scss +++ b/apps/insights/src/components/TokenIcon/index.module.scss @@ -2,7 +2,7 @@ .tokenIcon { display: inline-block; - background: theme.pallette-color("purple", 100); + background: theme.pallette-color("purple", 200); color: theme.pallette-color("steel", 950); padding: 0.35em; border-radius: theme.border-radius("full"); @@ -18,6 +18,7 @@ position: absolute; width: 100%; top: -0.05em; + left: 0; } } } diff --git a/apps/insights/src/components/PriceFeeds/layout.tsx b/apps/insights/src/components/ZoomLayoutTransition/index.tsx similarity index 94% rename from apps/insights/src/components/PriceFeeds/layout.tsx rename to apps/insights/src/components/ZoomLayoutTransition/index.tsx index 1474386b37..33afd0ac4b 100644 --- a/apps/insights/src/components/PriceFeeds/layout.tsx +++ b/apps/insights/src/components/ZoomLayoutTransition/index.tsx @@ -8,7 +8,7 @@ type Props = { children: ReactNode; }; -export const PriceFeedsLayout = ({ children }: Props) => ( +export const ZoomLayoutTransition = ({ children }: Props) => ( ({ diff --git a/apps/insights/src/config/server.ts b/apps/insights/src/config/server.ts index 0a09b1d3f9..48debc5a6a 100644 --- a/apps/insights/src/config/server.ts +++ b/apps/insights/src/config/server.ts @@ -50,3 +50,6 @@ export const CLICKHOUSE = { username: process.env.CLICKHOUSE_USERNAME ?? "insights", password: demand("CLICKHOUSE_PASSWORD"), }; + +export const SOLANA_RPC = + process.env.SOLANA_RPC ?? "https://api.mainnet-beta.solana.com"; diff --git a/apps/insights/src/services/clickhouse.ts b/apps/insights/src/services/clickhouse.ts index bd6afd0ff3..39d1410c28 100644 --- a/apps/insights/src/services/clickhouse.ts +++ b/apps/insights/src/services/clickhouse.ts @@ -1,16 +1,50 @@ import "server-only"; import { createClient } from "@clickhouse/client"; -import { z } from "zod"; +import { z, type ZodSchema, type ZodTypeDef } from "zod"; import { cache } from "../cache"; import { CLICKHOUSE } from "../config/server"; -export const client = createClient(CLICKHOUSE); +const client = createClient(CLICKHOUSE); -export const getRankings = cache(async (symbol: string) => { - const rows = await client.query({ - query: ` +const ONE_MINUTE_IN_SECONDS = 60; +const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS; + +export const getPublishers = cache( + async () => + safeQuery( + z.array( + z.strictObject({ + key: z.string(), + rank: z.number(), + numSymbols: z.number(), + medianScore: z.number(), + }), + ), + { + query: + "SELECT key, rank, numSymbols, medianScore FROM insights_publishers(cluster={cluster: String})", + query_params: { cluster: "pythnet" }, + }, + ), + ["publishers"], + { + revalidate: ONE_HOUR_IN_SECONDS, + }, +); + +export const getRankings = cache( + async (symbol: string) => + safeQuery( + z.array( + rankingSchema.extend({ + cluster: z.enum(["pythnet", "pythtest-conformance"]), + publisher: z.string(), + }), + ), + { + query: ` SELECT cluster, publisher, @@ -25,25 +59,153 @@ export const getRankings = cache(async (symbol: string) => { final_score FROM insights_feed_component_rankings(symbol={symbol: String}) `, - query_params: { symbol }, - }); - const result = await rows.json(); + query_params: { symbol }, + }, + ), + ["rankings"], + { + revalidate: ONE_HOUR_IN_SECONDS, + }, +); - return rankingsSchema.parse(result.data); +export const getPublisherFeeds = cache( + async (publisherKey: string) => + safeQuery( + z.array( + rankingSchema.extend({ + symbol: z.string(), + }), + ), + { + query: ` + SELECT + symbol, + uptime_score, + uptime_rank, + deviation_penalty, + deviation_score, + deviation_rank, + stalled_penalty, + stalled_score, + stalled_rank, + final_score + FROM insights_feeds_for_publisher(publisherKey={publisherKey: String}) + `, + query_params: { publisherKey }, + }, + ), + ["publisher-feeds"], + { + revalidate: ONE_HOUR_IN_SECONDS, + }, +); + +const rankingSchema = z.strictObject({ + uptime_score: z.number(), + uptime_rank: z.number(), + deviation_penalty: z.number().nullable(), + deviation_score: z.number(), + deviation_rank: z.number(), + stalled_penalty: z.number(), + stalled_score: z.number(), + stalled_rank: z.number(), + final_score: z.number(), }); -const rankingsSchema = z.array( - z.strictObject({ - cluster: z.enum(["pythnet", "pythtest-conformance"]), - publisher: z.string(), - uptime_score: z.number(), - uptime_rank: z.number(), - deviation_penalty: z.number().nullable(), - deviation_score: z.number(), - deviation_rank: z.number(), - stalled_penalty: z.number(), - stalled_score: z.number(), - stalled_rank: z.number(), - final_score: z.number(), - }), +export const getYesterdaysPrices = cache( + async (symbols: string[]) => + safeQuery( + z.array( + z.object({ + symbol: z.string(), + price: z.number(), + }), + ), + { + query: + "select symbol, price from insights_yesterdays_prices(symbols={symbols: Array(String)})", + query_params: { symbols }, + }, + ), + ["yesterdays-prices"], + { + revalidate: ONE_HOUR_IN_SECONDS, + }, +); + +export const getPublisherRankingHistory = cache( + async (key: string) => + safeQuery( + z.array( + z.strictObject({ + timestamp: z.string().transform((value) => new Date(value)), + rank: z.number(), + }), + ), + { + query: ` + SELECT * FROM ( + SELECT timestamp, rank + FROM publishers_ranking + WHERE publisher = {key: String} + AND cluster = 'pythnet' + ORDER BY timestamp DESC + LIMIT 30 + ) + ORDER BY timestamp ASC + `, + query_params: { key }, + }, + ), + ["publisher-ranking-history"], + { revalidate: ONE_HOUR_IN_SECONDS }, +); + +export const getPublisherMedianScoreHistory = cache( + async (key: string) => + safeQuery( + z.array( + z.strictObject({ + time: z.string().transform((value) => new Date(value)), + medianScore: z.number(), + medianUptimeScore: z.number(), + medianDeviationScore: z.number(), + medianStalledScore: z.number(), + }), + ), + { + query: ` + SELECT * FROM ( + SELECT + time, + medianExact(final_score) AS medianScore, + medianExact(uptime_score) AS medianUptimeScore, + medianExact(deviation_score) AS medianDeviationScore, + medianExact(stalled_score) AS medianStalledScore + FROM default.publisher_quality_ranking + WHERE publisher = {key: String} + AND cluster = 'pythnet' + GROUP BY time + ORDER BY time DESC + LIMIT 30 + ) + ORDER BY time ASC + `, + query_params: { key }, + }, + ), + ["publisher-median-score-history"], + { + revalidate: ONE_HOUR_IN_SECONDS, + }, ); + +const safeQuery = async ( + schema: ZodSchema, + query: Omit[0], "format">, +) => { + const rows = await client.query({ ...query, format: "JSON" }); + const result = await rows.json(); + + return schema.parse(result.data); +}; diff --git a/apps/insights/src/services/hermes.ts b/apps/insights/src/services/hermes.ts index 6c5637eeec..9961fcfd4b 100644 --- a/apps/insights/src/services/hermes.ts +++ b/apps/insights/src/services/hermes.ts @@ -2,4 +2,17 @@ import "server-only"; import { HermesClient } from "@pythnetwork/hermes-client"; -export const client = new HermesClient("https://hermes.pyth.network"); +import { cache } from "../cache"; + +const ONE_MINUTE_IN_SECONDS = 60; +const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS; + +const client = new HermesClient("https://hermes.pyth.network"); + +export const getPublisherCaps = cache( + async () => client.getLatestPublisherCaps({ parsed: true }), + ["publisher-caps"], + { + revalidate: ONE_HOUR_IN_SECONDS, + }, +); diff --git a/apps/insights/src/services/pyth.ts b/apps/insights/src/services/pyth.ts index 31f322e8b5..f3479dfbbf 100644 --- a/apps/insights/src/services/pyth.ts +++ b/apps/insights/src/services/pyth.ts @@ -10,21 +10,30 @@ import { z } from "zod"; import { cache } from "../cache"; -export const CLUSTER = "pythnet"; +const ONE_MINUTE_IN_SECONDS = 60; +const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS; +const CLUSTER = "pythnet"; + const connection = new Connection(getPythClusterApiUrl(CLUSTER)); const programKey = getPythProgramKeyForCluster(CLUSTER); export const client = new PythHttpClient(connection, programKey); -export const getData = cache(async () => { - const data = await client.getData(); - return priceFeedsSchema.parse( - data.symbols.map((symbol) => ({ - symbol, - product: data.productFromSymbol.get(symbol), - price: data.productPrice.get(symbol), - })), - ); -}); +export const getData = cache( + async () => { + const data = await client.getData(); + return priceFeedsSchema.parse( + data.symbols.map((symbol) => ({ + symbol, + product: data.productFromSymbol.get(symbol), + price: data.productPrice.get(symbol), + })), + ); + }, + ["pyth-data"], + { + revalidate: ONE_HOUR_IN_SECONDS, + }, +); const priceFeedsSchema = z.array( z.object({ @@ -57,6 +66,11 @@ const priceFeedsSchema = z.array( }), ); +export const getTotalFeedCount = async () => { + const pythData = await getData(); + return pythData.filter(({ price }) => price.numComponentPrices > 0).length; +}; + export const subscribe = (feeds: PublicKey[], cb: PythPriceCallback) => { const pythConn = new PythConnection( connection, diff --git a/apps/insights/src/services/staking.ts b/apps/insights/src/services/staking.ts index f7fe722289..276c0e1379 100644 --- a/apps/insights/src/services/staking.ts +++ b/apps/insights/src/services/staking.ts @@ -1,7 +1,75 @@ import "server-only"; -import { PythStakingClient } from "@pythnetwork/staking-sdk"; +import { + PythStakingClient, + epochToDate, + extractPublisherData, +} from "@pythnetwork/staking-sdk"; import { Connection } from "@solana/web3.js"; -const connection = new Connection("https://api.mainnet-beta.solana.com"); -export const client = new PythStakingClient({ connection }); +import { cache } from "../cache"; +import { SOLANA_RPC } from "../config/server"; + +const ONE_MINUTE_IN_SECONDS = 60; +const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS; + +const connection = new Connection(SOLANA_RPC); +const client = new PythStakingClient({ connection }); + +export const getPublisherPoolData = cache( + async () => { + const poolData = await client.getPoolDataAccount(); + const publisherData = extractPublisherData(poolData); + return publisherData.map( + ({ totalDelegation, totalDelegationDelta, pubkey, apyHistory }) => ({ + totalDelegation, + totalDelegationDelta, + pubkey: pubkey.toBase58(), + apyHistory: apyHistory.map(({ epoch, apy }) => ({ + date: epochToDate(epoch + 1n), + apy, + })), + }), + ); + }, + ["publisher-pool-data"], + { + revalidate: ONE_HOUR_IN_SECONDS, + }, +); + +export const getDelState = cache( + async () => { + const poolData = await client.getPoolDataAccount(); + return { + delState: poolData.delState, + selfDelState: poolData.selfDelState, + }; + }, + ["ois-del-state"], + { + revalidate: ONE_HOUR_IN_SECONDS, + }, +); + +export const getClaimableRewards = cache( + async () => { + const poolData = await client.getPoolDataAccount(); + return poolData.claimableRewards; + }, + ["ois-claimable-rewards"], + { + revalidate: ONE_HOUR_IN_SECONDS, + }, +); + +export const getDistributedRewards = cache( + async () => { + const rewardCustodyAccount = await client.getRewardCustodyAccount(); + return rewardCustodyAccount.amount; + }, + ["distributed-rewards"], + { + revalidate: ONE_HOUR_IN_SECONDS, + }, +); diff --git a/apps/insights/stylelint.config.js b/apps/insights/stylelint.config.js index f0c0f5ca97..d1a0ed4fc6 100644 --- a/apps/insights/stylelint.config.js +++ b/apps/insights/stylelint.config.js @@ -10,6 +10,12 @@ const config = { `Expected class selector "${selector}" to be camel-case`, }, ], + "selector-pseudo-class-no-unknown": [ + true, + { + ignorePseudoClasses: ["global", "export"], + }, + ], }, }; export default config; diff --git a/apps/insights/turbo.json b/apps/insights/turbo.json index 599ab3ef36..afda298c01 100644 --- a/apps/insights/turbo.json +++ b/apps/insights/turbo.json @@ -11,7 +11,8 @@ "AMPLITUDE_API_KEY", "CLICKHOUSE_URL", "CLICKHOUSE_USERNAME", - "CLICKHOUSE_PASSWORD" + "CLICKHOUSE_PASSWORD", + "SOLANA_RPC" ] }, "fix:lint": { diff --git a/packages/component-library/package.json b/packages/component-library/package.json index 9c1c92fc6b..91e5d61e14 100644 --- a/packages/component-library/package.json +++ b/packages/component-library/package.json @@ -57,7 +57,6 @@ "postcss-loader": "catalog:", "prettier": "catalog:", "react": "catalog:", - "react-dom": "catalog:", "sass": "catalog:", "sass-loader": "catalog:", "storybook": "catalog:", diff --git a/packages/component-library/src/Alert/index.tsx b/packages/component-library/src/Alert/index.tsx index e331882c1f..5126513a2a 100644 --- a/packages/component-library/src/Alert/index.tsx +++ b/packages/component-library/src/Alert/index.tsx @@ -40,9 +40,10 @@ export const Alert = ({ transition: { type: "spring", duration: 0.75, bounce: 0.5 }, }, hidden: { - y: "100%", + y: "calc(100% + 2rem)", transition: { ease: "linear", duration: CLOSE_DURATION_IN_S }, }, + unmounted: { y: "calc(100% + 2rem)" }, }} className={clsx(styles.alert, className)} {...props} diff --git a/packages/component-library/src/Breadcrumbs/index.tsx b/packages/component-library/src/Breadcrumbs/index.tsx index b8963ced99..33cacf6897 100644 --- a/packages/component-library/src/Breadcrumbs/index.tsx +++ b/packages/component-library/src/Breadcrumbs/index.tsx @@ -3,7 +3,7 @@ import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight"; import { House } from "@phosphor-icons/react/dist/ssr/House"; import clsx from "clsx"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; import styles from "./index.module.scss"; import { Button } from "../Button/index.js"; @@ -20,7 +20,7 @@ type OwnProps = { href: string; label: string; }[], - { label: string }, + { label: ReactNode }, ]; }; type Props = Omit, keyof OwnProps> & @@ -30,10 +30,7 @@ export const Breadcrumbs = ({ label, className, items, ...props }: Props) => (