index.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715
  1. "use client";
  2. import { Badge } from "@pythnetwork/component-library/Badge";
  3. import { Button } from "@pythnetwork/component-library/Button";
  4. import { Card } from "@pythnetwork/component-library/Card";
  5. import { EntityList } from "@pythnetwork/component-library/EntityList";
  6. import { NoResults } from "@pythnetwork/component-library/NoResults";
  7. import { Paginator } from "@pythnetwork/component-library/Paginator";
  8. import { SearchInput } from "@pythnetwork/component-library/SearchInput";
  9. import { Select } from "@pythnetwork/component-library/Select";
  10. import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup";
  11. import type {
  12. RowConfig,
  13. ColumnConfig,
  14. SortDescriptor,
  15. } from "@pythnetwork/component-library/Table";
  16. import { Table } from "@pythnetwork/component-library/Table";
  17. import { useLogger } from "@pythnetwork/component-library/useLogger";
  18. import clsx from "clsx";
  19. import { useQueryState, parseAsStringEnum, parseAsBoolean } from "nuqs";
  20. import type { ReactNode } from "react";
  21. import { Fragment, Suspense, useMemo, useCallback } from "react";
  22. import { useFilter, useCollator } from "react-aria";
  23. import styles from "./index.module.scss";
  24. import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
  25. import { Cluster } from "../../services/pyth";
  26. import type { StatusName } from "../../status";
  27. import {
  28. STATUS_NAMES,
  29. Status as StatusType,
  30. statusNameToStatus,
  31. } from "../../status";
  32. import { Explain } from "../Explain";
  33. import { EvaluationTime } from "../Explanations";
  34. import { FormattedNumber } from "../FormattedNumber";
  35. import { LivePrice, LiveConfidence, LiveComponentValue } from "../LivePrices";
  36. import { usePriceComponentDrawer } from "../PriceComponentDrawer";
  37. import { PriceName } from "../PriceName";
  38. import { Score } from "../Score";
  39. import { Status as StatusComponent } from "../Status";
  40. const SCORE_WIDTH = 32;
  41. type Props<U extends string, T extends PriceComponent & Record<U, unknown>> = {
  42. className?: string | undefined;
  43. nameLoadingSkeleton: ReactNode;
  44. label: string;
  45. searchPlaceholder: string;
  46. toolbarExtra?: ReactNode;
  47. assetClass?: string | undefined;
  48. extraColumns?: ColumnConfig<U>[] | undefined;
  49. nameWidth?: number | undefined;
  50. identifiesPublisher?: boolean | undefined;
  51. } & (
  52. | {
  53. isLoading: true;
  54. }
  55. | {
  56. isLoading?: false | undefined;
  57. priceComponents: T[];
  58. metricsTime?: Date | undefined;
  59. }
  60. );
  61. export type PriceComponent = {
  62. id: string;
  63. score: number | undefined;
  64. rank: number | undefined;
  65. symbol: string;
  66. displaySymbol: string;
  67. firstEvaluation?: Date | undefined;
  68. assetClass: string;
  69. uptimeScore: number | undefined;
  70. deviationScore: number | undefined;
  71. stalledScore: number | undefined;
  72. cluster: Cluster;
  73. status: StatusType;
  74. feedKey: string;
  75. publisherKey: string;
  76. name: ReactNode;
  77. nameAsString: string;
  78. };
  79. export const PriceComponentsCard = <
  80. U extends string,
  81. T extends PriceComponent & Record<U, unknown>,
  82. >(
  83. props: Props<U, T>,
  84. ) => {
  85. if (props.isLoading) {
  86. return <PriceComponentsCardContents {...props} />;
  87. } else {
  88. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  89. const { isLoading, priceComponents, ...otherProps } = props;
  90. return (
  91. <Suspense
  92. fallback={<PriceComponentsCardContents isLoading {...otherProps} />}
  93. >
  94. <ResolvedPriceComponentsCard
  95. priceComponents={priceComponents}
  96. {...otherProps}
  97. />
  98. </Suspense>
  99. );
  100. }
  101. };
  102. export const ResolvedPriceComponentsCard = <
  103. U extends string,
  104. T extends PriceComponent & Record<U, unknown>,
  105. >({
  106. priceComponents,
  107. identifiesPublisher,
  108. ...props
  109. }: Omit<Props<U, T>, "isLoading"> & {
  110. priceComponents: T[];
  111. metricsTime?: Date | undefined;
  112. }) => {
  113. const logger = useLogger();
  114. const collator = useCollator();
  115. const filter = useFilter({ sensitivity: "base", usage: "search" });
  116. const { selectComponent } = usePriceComponentDrawer({
  117. components: priceComponents,
  118. identifiesPublisher,
  119. });
  120. const [status, setStatus] = useQueryState(
  121. "status",
  122. parseAsStringEnum(["", ...Object.values(STATUS_NAMES)]).withDefault(""),
  123. );
  124. const [showQuality, setShowQuality] = useQueryState(
  125. "showQuality",
  126. parseAsBoolean.withDefault(false),
  127. );
  128. const statusType = useMemo(() => statusNameToStatus(status), [status]);
  129. const componentsFilteredByStatus = useMemo(
  130. () =>
  131. statusType === undefined
  132. ? priceComponents
  133. : priceComponents.filter(
  134. (component) => component.status === statusType,
  135. ),
  136. [statusType, priceComponents],
  137. );
  138. const {
  139. search,
  140. sortDescriptor,
  141. page,
  142. pageSize,
  143. updateSearch,
  144. updateSortDescriptor,
  145. updatePage,
  146. updatePageSize,
  147. paginatedItems,
  148. numResults,
  149. numPages,
  150. mkPageLink,
  151. } = useQueryParamFilterPagination(
  152. componentsFilteredByStatus,
  153. (component, search) => filter.contains(component.nameAsString, search),
  154. (a, b, { column, direction }) => {
  155. switch (column) {
  156. case "score":
  157. case "uptimeScore":
  158. case "deviationScore":
  159. case "stalledScore": {
  160. if (a[column] === undefined && b[column] === undefined) {
  161. return 0;
  162. } else if (a[column] === undefined) {
  163. return direction === "descending" ? 1 : -1;
  164. } else if (b[column] === undefined) {
  165. return direction === "descending" ? -1 : 1;
  166. } else {
  167. return (
  168. (direction === "descending" ? -1 : 1) * (a[column] - b[column])
  169. );
  170. }
  171. }
  172. case "name": {
  173. return (
  174. (direction === "descending" ? -1 : 1) *
  175. collator.compare(a.nameAsString, b.nameAsString)
  176. );
  177. }
  178. case "status": {
  179. const resultByStatus = b.status - a.status;
  180. const result =
  181. resultByStatus === 0
  182. ? collator.compare(a.nameAsString, b.nameAsString)
  183. : resultByStatus;
  184. return (direction === "descending" ? -1 : 1) * result;
  185. }
  186. default: {
  187. return 0;
  188. }
  189. }
  190. },
  191. {
  192. defaultPageSize: 20,
  193. defaultSort: "name",
  194. defaultDescending: false,
  195. },
  196. );
  197. const rows = useMemo(
  198. () =>
  199. paginatedItems.map((component) => ({
  200. id: component.id,
  201. nameAsString: component.nameAsString,
  202. onAction: () => {
  203. selectComponent(component);
  204. },
  205. data: {
  206. name: component.name,
  207. ...Object.fromEntries(
  208. props.extraColumns?.map((column) => [
  209. column.id,
  210. component[column.id],
  211. ]) ?? [],
  212. ),
  213. score: component.score !== undefined && (
  214. <Score score={component.score} width={SCORE_WIDTH} />
  215. ),
  216. uptimeScore: component.uptimeScore !== undefined && (
  217. <FormattedNumber
  218. value={component.uptimeScore}
  219. maximumSignificantDigits={5}
  220. />
  221. ),
  222. deviationScore: component.deviationScore !== undefined && (
  223. <FormattedNumber
  224. value={component.deviationScore}
  225. maximumSignificantDigits={5}
  226. />
  227. ),
  228. stalledScore: component.stalledScore !== undefined && (
  229. <FormattedNumber
  230. value={component.stalledScore}
  231. maximumSignificantDigits={5}
  232. />
  233. ),
  234. slot: (
  235. <LiveComponentValue
  236. feedKey={component.feedKey}
  237. publisherKey={component.publisherKey}
  238. field="publishSlot"
  239. cluster={component.cluster}
  240. />
  241. ),
  242. price: (
  243. <LivePrice
  244. feedKey={component.feedKey}
  245. publisherKey={component.publisherKey}
  246. cluster={component.cluster}
  247. />
  248. ),
  249. confidence: (
  250. <LiveConfidence
  251. feedKey={component.feedKey}
  252. publisherKey={component.publisherKey}
  253. cluster={component.cluster}
  254. />
  255. ),
  256. status: <StatusComponent status={component.status} />,
  257. },
  258. })),
  259. [paginatedItems, props.extraColumns, selectComponent],
  260. );
  261. const updateStatus = useCallback(
  262. (newStatus: StatusName | "") => {
  263. updatePage(1);
  264. setStatus(newStatus).catch((error: unknown) => {
  265. logger.error("Failed to update status", error);
  266. });
  267. },
  268. [updatePage, setStatus, logger],
  269. );
  270. const updateShowQuality = useCallback(
  271. (newValue: boolean) => {
  272. setShowQuality(newValue).catch((error: unknown) => {
  273. logger.error("Failed to update show quality", error);
  274. });
  275. },
  276. [setShowQuality, logger],
  277. );
  278. return (
  279. <PriceComponentsCardContents
  280. numResults={numResults}
  281. search={search}
  282. sortDescriptor={sortDescriptor}
  283. numPages={numPages}
  284. page={page}
  285. pageSize={pageSize}
  286. onSearchChange={updateSearch}
  287. onSortChange={updateSortDescriptor}
  288. onPageSizeChange={updatePageSize}
  289. onPageChange={updatePage}
  290. mkPageLink={mkPageLink}
  291. rows={rows}
  292. status={status}
  293. onStatusChange={updateStatus}
  294. showQuality={showQuality}
  295. setShowQuality={updateShowQuality}
  296. {...props}
  297. />
  298. );
  299. };
  300. type PriceComponentsCardProps<
  301. U extends string,
  302. T extends PriceComponent & Record<U, unknown>,
  303. > = Pick<
  304. Props<U, T>,
  305. | "className"
  306. | "nameLoadingSkeleton"
  307. | "label"
  308. | "searchPlaceholder"
  309. | "toolbarExtra"
  310. | "assetClass"
  311. | "extraColumns"
  312. | "nameWidth"
  313. > &
  314. (
  315. | { isLoading: true }
  316. | {
  317. isLoading?: false;
  318. metricsTime?: Date | undefined;
  319. numResults: number;
  320. search: string;
  321. sortDescriptor: SortDescriptor;
  322. numPages: number;
  323. page: number;
  324. pageSize: number;
  325. onSearchChange: (newSearch: string) => void;
  326. onSortChange: (newSort: SortDescriptor) => void;
  327. onPageSizeChange: (newPageSize: number) => void;
  328. onPageChange: (newPage: number) => void;
  329. mkPageLink: (page: number) => string;
  330. status: StatusName | "";
  331. onStatusChange: (newStatus: StatusName | "") => void;
  332. showQuality: boolean;
  333. setShowQuality: (newValue: boolean) => void;
  334. rows: (RowConfig<string> & { nameAsString: string })[];
  335. }
  336. );
  337. export const PriceComponentsCardContents = <
  338. U extends string,
  339. T extends PriceComponent & Record<U, unknown>,
  340. >({
  341. className,
  342. nameLoadingSkeleton,
  343. label,
  344. searchPlaceholder,
  345. toolbarExtra,
  346. extraColumns,
  347. nameWidth,
  348. ...props
  349. }: PriceComponentsCardProps<U, T>) => {
  350. const collator = useCollator();
  351. return (
  352. <Card
  353. className={clsx(className, styles.priceComponentsCard)}
  354. title={
  355. <>
  356. <span>{label}</span>
  357. <Badge style="filled" variant="neutral" size="md">
  358. {!props.isLoading && props.numResults}
  359. </Badge>
  360. </>
  361. }
  362. toolbar={
  363. <div className={styles.toolbar}>
  364. {toolbarExtra && (
  365. <div data-section="extra" className={styles.toolbarSection}>
  366. {toolbarExtra}
  367. </div>
  368. )}
  369. <div data-section="search" className={styles.toolbarSection}>
  370. <Select<{ id: StatusName | "" }>
  371. label="Status"
  372. size="sm"
  373. variant="outline"
  374. hideLabel
  375. options={[
  376. { id: "" },
  377. ...Object.values(STATUS_NAMES)
  378. .toSorted((a, b) => collator.compare(a, b))
  379. .map((id) => ({ id })),
  380. ]}
  381. {...(props.isLoading
  382. ? { isPending: true, buttonLabel: "Status" }
  383. : {
  384. show: ({ id }) => (id === "" ? "All" : id),
  385. placement: "bottom end",
  386. buttonLabel: props.status === "" ? "Status" : props.status,
  387. selectedKey: props.status,
  388. onSelectionChange: props.onStatusChange,
  389. })}
  390. />
  391. <SearchInput
  392. size="sm"
  393. width={60}
  394. placeholder={searchPlaceholder}
  395. className={styles.searchInput ?? ""}
  396. {...(props.isLoading
  397. ? { isPending: true, isDisabled: true }
  398. : {
  399. value: props.search,
  400. onChange: props.onSearchChange,
  401. })}
  402. />
  403. </div>
  404. <div data-section="mode" className={styles.toolbarSection}>
  405. <SingleToggleGroup
  406. className={styles.modeSelect ?? ""}
  407. {...(!props.isLoading && {
  408. selectedKey: props.showQuality ? "quality" : "prices",
  409. onSelectionChange: (newValue) => {
  410. props.setShowQuality(newValue === "quality");
  411. },
  412. })}
  413. items={[
  414. {
  415. id: "prices",
  416. children: <PriceName assetClass={props.assetClass} plural />,
  417. },
  418. { id: "quality", children: "Quality" },
  419. ]}
  420. />
  421. </div>
  422. </div>
  423. }
  424. {...(!props.isLoading && {
  425. footer: (
  426. <Paginator
  427. numPages={props.numPages}
  428. currentPage={props.page}
  429. onPageChange={props.onPageChange}
  430. pageSize={props.pageSize}
  431. onPageSizeChange={props.onPageSizeChange}
  432. pageSizeOptions={[10, 20, 30, 40, 50]}
  433. mkPageLink={props.mkPageLink}
  434. />
  435. ),
  436. })}
  437. >
  438. <EntityList
  439. label={label}
  440. className={styles.entityList ?? ""}
  441. headerLoadingSkeleton={nameLoadingSkeleton}
  442. fields={[
  443. { id: "slot", name: "Slot" },
  444. { id: "price", name: "Price" },
  445. { id: "confidence", name: "Confidence" },
  446. { id: "uptimeScore", name: "Uptime Score" },
  447. { id: "deviationScore", name: "Deviation Score" },
  448. { id: "stalledScore", name: "Stalled Score" },
  449. { id: "score", name: "Final Score" },
  450. { id: "status", name: "Status" },
  451. ]}
  452. isLoading={props.isLoading}
  453. rows={
  454. props.isLoading
  455. ? []
  456. : props.rows.map((row) => ({
  457. ...row,
  458. textValue: row.nameAsString,
  459. header: (
  460. <>
  461. {row.data.name}
  462. {extraColumns?.map((column) => (
  463. <Fragment key={column.id}>{row.data[column.id]}</Fragment>
  464. ))}
  465. </>
  466. ),
  467. }))
  468. }
  469. />
  470. <Table
  471. label={label}
  472. fill
  473. rounded
  474. stickyHeader="appHeader"
  475. className={styles.table ?? ""}
  476. columns={[
  477. {
  478. id: "name",
  479. name: "NAME / ID",
  480. alignment: "left",
  481. isRowHeader: true,
  482. loadingSkeleton: nameLoadingSkeleton,
  483. allowsSorting: true,
  484. ...(nameWidth !== undefined && { width: nameWidth }),
  485. },
  486. ...(extraColumns ?? []),
  487. ...otherColumns(props),
  488. {
  489. id: "status",
  490. width: 20,
  491. name: (
  492. <>
  493. STATUS
  494. <Explain size="xs" title="Status">
  495. A publisher{"'"}s feed have one of the following statuses:
  496. <ul>
  497. <li>
  498. <b>Active</b> feeds have better than 50% uptime over the
  499. last day
  500. </li>
  501. <li>
  502. <b>Inactive</b> feeds have worse than 50% uptime over the
  503. last day
  504. </li>
  505. <li>
  506. <b>Unranked</b> feeds have not yet been evaluated by Pyth
  507. </li>
  508. </ul>
  509. {!props.isLoading && props.metricsTime && (
  510. <EvaluationTime scoreTime={props.metricsTime} />
  511. )}
  512. </Explain>
  513. </>
  514. ),
  515. alignment: "right",
  516. allowsSorting: true,
  517. },
  518. ]}
  519. {...(props.isLoading
  520. ? { isLoading: true }
  521. : {
  522. rows: props.rows,
  523. sortDescriptor: props.sortDescriptor,
  524. onSortChange: props.onSortChange,
  525. emptyState: (
  526. <NoResults
  527. query={props.search}
  528. onClearSearch={() => {
  529. props.onSearchChange("");
  530. props.onStatusChange("");
  531. }}
  532. />
  533. ),
  534. })}
  535. />
  536. </Card>
  537. );
  538. };
  539. const otherColumns = ({
  540. metricsTime,
  541. assetClass,
  542. ...props
  543. }: { metricsTime?: Date | undefined; assetClass?: string | undefined } & (
  544. | { isLoading: true }
  545. | { isLoading?: false; showQuality: boolean }
  546. )) => {
  547. if (props.isLoading) {
  548. return [];
  549. } else {
  550. return props.showQuality
  551. ? [
  552. {
  553. id: "uptimeScore",
  554. width: 20,
  555. name: (
  556. <>
  557. UPTIME SCORE
  558. <Explain size="xs" title="Uptime">
  559. <p>
  560. Uptime is the percentage of time that a publisher{"'"}s feed
  561. is available and active.
  562. </p>
  563. {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
  564. <Button
  565. href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking#uptime-1"
  566. size="xs"
  567. variant="solid"
  568. target="_blank"
  569. >
  570. Read more
  571. </Button>
  572. </Explain>
  573. </>
  574. ),
  575. alignment: "center" as const,
  576. allowsSorting: true,
  577. },
  578. {
  579. id: "deviationScore",
  580. width: 20,
  581. name: (
  582. <>
  583. DEVIATION SCORE
  584. <Explain size="xs" title="Deviation">
  585. <p>
  586. Deviation measures how close a publisher{"'"}s quote is to
  587. what Pyth believes to be the true market quote.
  588. </p>
  589. <p>
  590. Note that publishers must have an uptime of at least 50% to
  591. be ranked. If a publisher{"'"}s uptime is less than 50%,
  592. then the deviation and the stalled score of the publisher
  593. will be 0 to reflect their ineligibility.
  594. </p>
  595. {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
  596. <Button
  597. href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking#price-deviation-1"
  598. size="xs"
  599. variant="solid"
  600. target="_blank"
  601. >
  602. Read more
  603. </Button>
  604. </Explain>
  605. </>
  606. ),
  607. alignment: "center" as const,
  608. allowsSorting: true,
  609. },
  610. {
  611. id: "stalledScore",
  612. width: 20,
  613. name: (
  614. <>
  615. STALLED SCORE
  616. <Explain size="xs" title="Stalled">
  617. <p>
  618. A feed is considered stalled if it is publishing the same
  619. value repeatedly for the quote. This score component is
  620. reduced each time a feed is stalled.
  621. </p>
  622. <p>
  623. Note that publishers must have an uptime of at least 50% to
  624. be ranked. If a publisher{"'"}s uptime is less than 50%,
  625. then the deviation and the stalled score of the publisher
  626. will be 0 to reflect their ineligibility.
  627. </p>
  628. {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
  629. <Button
  630. href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking#lack-of-stalled-prices-1"
  631. size="xs"
  632. variant="solid"
  633. target="_blank"
  634. >
  635. Read more
  636. </Button>
  637. </Explain>
  638. </>
  639. ),
  640. alignment: "center" as const,
  641. allowsSorting: true,
  642. },
  643. {
  644. id: "score",
  645. name: (
  646. <>
  647. FINAL SCORE
  648. <Explain size="xs" title="Uptime">
  649. The final score is calculated by combining the three score
  650. components as follows:
  651. <ul>
  652. <li>
  653. <b>Uptime Score</b> (40% weight)
  654. </li>
  655. <li>
  656. <b>Deviation Score</b> (40% weight)
  657. </li>
  658. <li>
  659. <b>Stalled Score</b> (20% weight)
  660. </li>
  661. </ul>
  662. {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
  663. <Button
  664. href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking"
  665. size="xs"
  666. variant="solid"
  667. target="_blank"
  668. >
  669. Read more
  670. </Button>
  671. </Explain>
  672. </>
  673. ),
  674. alignment: "left" as const,
  675. width: SCORE_WIDTH,
  676. loadingSkeleton: <Score isLoading width={SCORE_WIDTH} />,
  677. allowsSorting: true,
  678. },
  679. ]
  680. : [
  681. { id: "slot", name: "SLOT", alignment: "left" as const, width: 40 },
  682. {
  683. id: "price",
  684. name: <PriceName assetClass={assetClass} uppercase />,
  685. alignment: "left" as const,
  686. width: 40,
  687. },
  688. {
  689. id: "confidence",
  690. name: "CONFIDENCE INTERVAL",
  691. alignment: "left" as const,
  692. width: 50,
  693. },
  694. ];
  695. }
  696. };