|
|
@@ -0,0 +1,600 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import { useLogger } from "@pythnetwork/app-logger";
|
|
|
+import { Badge } from "@pythnetwork/component-library/Badge";
|
|
|
+import { Button } from "@pythnetwork/component-library/Button";
|
|
|
+import { Card } from "@pythnetwork/component-library/Card";
|
|
|
+import { Paginator } from "@pythnetwork/component-library/Paginator";
|
|
|
+import { SearchInput } from "@pythnetwork/component-library/SearchInput";
|
|
|
+import { Select } from "@pythnetwork/component-library/Select";
|
|
|
+import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup";
|
|
|
+import {
|
|
|
+ type RowConfig,
|
|
|
+ type SortDescriptor,
|
|
|
+ Table,
|
|
|
+} from "@pythnetwork/component-library/Table";
|
|
|
+import { useQueryState, parseAsStringEnum, parseAsBoolean } from "nuqs";
|
|
|
+import { type ReactNode, Suspense, useMemo, useCallback } from "react";
|
|
|
+import { useFilter, useCollator } from "react-aria";
|
|
|
+
|
|
|
+import styles from "./index.module.scss";
|
|
|
+import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
|
|
|
+import { Cluster } from "../../services/pyth";
|
|
|
+import {
|
|
|
+ type StatusName,
|
|
|
+ STATUS_NAMES,
|
|
|
+ Status as StatusType,
|
|
|
+ statusNameToStatus,
|
|
|
+} from "../../status";
|
|
|
+import { Explain } from "../Explain";
|
|
|
+import { EvaluationTime } from "../Explanations";
|
|
|
+import { FormattedNumber } from "../FormattedNumber";
|
|
|
+import { LivePrice, LiveConfidence, LiveComponentValue } from "../LivePrices";
|
|
|
+import { NoResults } from "../NoResults";
|
|
|
+import rootStyles from "../Root/index.module.scss";
|
|
|
+import { Score } from "../Score";
|
|
|
+import { Status as StatusComponent } from "../Status";
|
|
|
+
|
|
|
+const SCORE_WIDTH = 32;
|
|
|
+
|
|
|
+type Props = {
|
|
|
+ className?: string | undefined;
|
|
|
+ priceComponents: PriceComponent[];
|
|
|
+ metricsTime?: Date | undefined;
|
|
|
+ nameLoadingSkeleton: ReactNode;
|
|
|
+ label: string;
|
|
|
+ searchPlaceholder: string;
|
|
|
+ onPriceComponentAction: (component: PriceComponent) => void;
|
|
|
+};
|
|
|
+
|
|
|
+type PriceComponent = {
|
|
|
+ id: string;
|
|
|
+ score: number | undefined;
|
|
|
+ symbol: string;
|
|
|
+ uptimeScore: number | undefined;
|
|
|
+ deviationScore: number | undefined;
|
|
|
+ stalledScore: number | undefined;
|
|
|
+ cluster: Cluster;
|
|
|
+ status: StatusType;
|
|
|
+ feedKey: string;
|
|
|
+ publisherKey: string;
|
|
|
+ name: ReactNode;
|
|
|
+ nameAsString: string;
|
|
|
+};
|
|
|
+
|
|
|
+export const PriceComponentsCard = ({
|
|
|
+ priceComponents,
|
|
|
+ onPriceComponentAction,
|
|
|
+ ...props
|
|
|
+}: Props) => (
|
|
|
+ <Suspense fallback={<PriceComponentsCardContents isLoading {...props} />}>
|
|
|
+ <ResolvedPriceComponentsCard
|
|
|
+ priceComponents={priceComponents}
|
|
|
+ onPriceComponentAction={onPriceComponentAction}
|
|
|
+ {...props}
|
|
|
+ />
|
|
|
+ </Suspense>
|
|
|
+);
|
|
|
+
|
|
|
+export const ResolvedPriceComponentsCard = ({
|
|
|
+ priceComponents,
|
|
|
+ onPriceComponentAction,
|
|
|
+ ...props
|
|
|
+}: Props) => {
|
|
|
+ const logger = useLogger();
|
|
|
+ const collator = useCollator();
|
|
|
+ const filter = useFilter({ sensitivity: "base", usage: "search" });
|
|
|
+ const [status, setStatus] = useQueryState(
|
|
|
+ "status",
|
|
|
+ parseAsStringEnum(["", ...Object.values(STATUS_NAMES)]).withDefault(""),
|
|
|
+ );
|
|
|
+ const [showQuality, setShowQuality] = useQueryState(
|
|
|
+ "showQuality",
|
|
|
+ parseAsBoolean.withDefault(false),
|
|
|
+ );
|
|
|
+ const statusType = useMemo(() => statusNameToStatus(status), [status]);
|
|
|
+ const componentsFilteredByStatus = useMemo(
|
|
|
+ () =>
|
|
|
+ statusType === undefined
|
|
|
+ ? priceComponents
|
|
|
+ : priceComponents.filter(
|
|
|
+ (component) => component.status === statusType,
|
|
|
+ ),
|
|
|
+ [statusType, priceComponents],
|
|
|
+ );
|
|
|
+
|
|
|
+ const {
|
|
|
+ search,
|
|
|
+ sortDescriptor,
|
|
|
+ page,
|
|
|
+ pageSize,
|
|
|
+ updateSearch,
|
|
|
+ updateSortDescriptor,
|
|
|
+ updatePage,
|
|
|
+ updatePageSize,
|
|
|
+ paginatedItems,
|
|
|
+ numResults,
|
|
|
+ numPages,
|
|
|
+ mkPageLink,
|
|
|
+ } = useQueryParamFilterPagination(
|
|
|
+ componentsFilteredByStatus,
|
|
|
+ (component, search) => filter.contains(component.nameAsString, search),
|
|
|
+ (a, b, { column, direction }) => {
|
|
|
+ switch (column) {
|
|
|
+ case "score":
|
|
|
+ case "uptimeScore":
|
|
|
+ case "deviationScore":
|
|
|
+ case "stalledScore": {
|
|
|
+ if (a[column] === undefined && b[column] === undefined) {
|
|
|
+ return 0;
|
|
|
+ } else if (a[column] === undefined) {
|
|
|
+ return direction === "descending" ? 1 : -1;
|
|
|
+ } else if (b[column] === undefined) {
|
|
|
+ return direction === "descending" ? -1 : 1;
|
|
|
+ } else {
|
|
|
+ return (
|
|
|
+ (direction === "descending" ? -1 : 1) * (a[column] - b[column])
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ case "name": {
|
|
|
+ return (
|
|
|
+ (direction === "descending" ? -1 : 1) *
|
|
|
+ collator.compare(a.nameAsString, b.nameAsString)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ case "status": {
|
|
|
+ const resultByStatus = b.status - a.status;
|
|
|
+ const result =
|
|
|
+ resultByStatus === 0
|
|
|
+ ? collator.compare(a.nameAsString, b.nameAsString)
|
|
|
+ : resultByStatus;
|
|
|
+
|
|
|
+ return (direction === "descending" ? -1 : 1) * result;
|
|
|
+ }
|
|
|
+
|
|
|
+ default: {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ defaultPageSize: 20,
|
|
|
+ defaultSort: "name",
|
|
|
+ defaultDescending: false,
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ const rows = useMemo(
|
|
|
+ () =>
|
|
|
+ paginatedItems.map((component) => ({
|
|
|
+ id: component.id,
|
|
|
+ data: {
|
|
|
+ name: (
|
|
|
+ <div className={styles.componentName}>
|
|
|
+ {component.name}
|
|
|
+ {component.cluster === Cluster.PythtestConformance && (
|
|
|
+ <Badge variant="muted" style="filled" size="xs">
|
|
|
+ test
|
|
|
+ </Badge>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ ),
|
|
|
+ ...(showQuality
|
|
|
+ ? {
|
|
|
+ score: component.score !== undefined && (
|
|
|
+ <Score score={component.score} width={SCORE_WIDTH} />
|
|
|
+ ),
|
|
|
+ uptimeScore: component.uptimeScore !== undefined && (
|
|
|
+ <FormattedNumber
|
|
|
+ value={component.uptimeScore}
|
|
|
+ maximumSignificantDigits={5}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ deviationScore: component.deviationScore !== undefined && (
|
|
|
+ <FormattedNumber
|
|
|
+ value={component.deviationScore}
|
|
|
+ maximumSignificantDigits={5}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ stalledScore: component.stalledScore !== undefined && (
|
|
|
+ <FormattedNumber
|
|
|
+ value={component.stalledScore}
|
|
|
+ maximumSignificantDigits={5}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ : {
|
|
|
+ slot: (
|
|
|
+ <LiveComponentValue
|
|
|
+ feedKey={component.feedKey}
|
|
|
+ publisherKey={component.publisherKey}
|
|
|
+ field="publishSlot"
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ price: (
|
|
|
+ <LivePrice
|
|
|
+ feedKey={component.feedKey}
|
|
|
+ publisherKey={component.publisherKey}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ confidence: (
|
|
|
+ <LiveConfidence
|
|
|
+ feedKey={component.feedKey}
|
|
|
+ publisherKey={component.publisherKey}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ }),
|
|
|
+ status: <StatusComponent status={component.status} />,
|
|
|
+ },
|
|
|
+ onAction: () => {
|
|
|
+ onPriceComponentAction(component);
|
|
|
+ },
|
|
|
+ })),
|
|
|
+ [paginatedItems, showQuality, onPriceComponentAction],
|
|
|
+ );
|
|
|
+
|
|
|
+ const updateStatus = useCallback(
|
|
|
+ (newStatus: StatusName | "") => {
|
|
|
+ updatePage(1);
|
|
|
+ setStatus(newStatus).catch((error: unknown) => {
|
|
|
+ logger.error("Failed to update status", error);
|
|
|
+ });
|
|
|
+ },
|
|
|
+ [updatePage, setStatus, logger],
|
|
|
+ );
|
|
|
+
|
|
|
+ const updateShowQuality = useCallback(
|
|
|
+ (newValue: boolean) => {
|
|
|
+ setShowQuality(newValue).catch((error: unknown) => {
|
|
|
+ logger.error("Failed to update show quality", error);
|
|
|
+ });
|
|
|
+ },
|
|
|
+ [setShowQuality, logger],
|
|
|
+ );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <PriceComponentsCardContents
|
|
|
+ numResults={numResults}
|
|
|
+ search={search}
|
|
|
+ sortDescriptor={sortDescriptor}
|
|
|
+ numPages={numPages}
|
|
|
+ page={page}
|
|
|
+ pageSize={pageSize}
|
|
|
+ onSearchChange={updateSearch}
|
|
|
+ onSortChange={updateSortDescriptor}
|
|
|
+ onPageSizeChange={updatePageSize}
|
|
|
+ onPageChange={updatePage}
|
|
|
+ mkPageLink={mkPageLink}
|
|
|
+ rows={rows}
|
|
|
+ status={status}
|
|
|
+ onStatusChange={updateStatus}
|
|
|
+ showQuality={showQuality}
|
|
|
+ setShowQuality={updateShowQuality}
|
|
|
+ {...props}
|
|
|
+ />
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+type PriceComponentsCardProps = Pick<
|
|
|
+ Props,
|
|
|
+ | "className"
|
|
|
+ | "metricsTime"
|
|
|
+ | "nameLoadingSkeleton"
|
|
|
+ | "label"
|
|
|
+ | "searchPlaceholder"
|
|
|
+> &
|
|
|
+ (
|
|
|
+ | { isLoading: true }
|
|
|
+ | {
|
|
|
+ isLoading?: false;
|
|
|
+ numResults: number;
|
|
|
+ search: string;
|
|
|
+ sortDescriptor: SortDescriptor;
|
|
|
+ numPages: number;
|
|
|
+ page: number;
|
|
|
+ pageSize: number;
|
|
|
+ onSearchChange: (newSearch: string) => void;
|
|
|
+ onSortChange: (newSort: SortDescriptor) => void;
|
|
|
+ onPageSizeChange: (newPageSize: number) => void;
|
|
|
+ onPageChange: (newPage: number) => void;
|
|
|
+ mkPageLink: (page: number) => string;
|
|
|
+ status: StatusName | "";
|
|
|
+ onStatusChange: (newStatus: StatusName | "") => void;
|
|
|
+ showQuality: boolean;
|
|
|
+ setShowQuality: (newValue: boolean) => void;
|
|
|
+ rows: RowConfig<string>[];
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+export const PriceComponentsCardContents = ({
|
|
|
+ className,
|
|
|
+ metricsTime,
|
|
|
+ nameLoadingSkeleton,
|
|
|
+ label,
|
|
|
+ searchPlaceholder,
|
|
|
+ ...props
|
|
|
+}: PriceComponentsCardProps) => {
|
|
|
+ const collator = useCollator();
|
|
|
+ return (
|
|
|
+ <Card
|
|
|
+ className={className}
|
|
|
+ title={
|
|
|
+ <>
|
|
|
+ <span>{label}</span>
|
|
|
+ {!props.isLoading && (
|
|
|
+ <Badge style="filled" variant="neutral" size="md">
|
|
|
+ {props.numResults}
|
|
|
+ </Badge>
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ }
|
|
|
+ toolbar={
|
|
|
+ <>
|
|
|
+ <Select<StatusName | "">
|
|
|
+ label="Status"
|
|
|
+ size="sm"
|
|
|
+ variant="outline"
|
|
|
+ hideLabel
|
|
|
+ options={[
|
|
|
+ "",
|
|
|
+ ...Object.values(STATUS_NAMES).toSorted((a, b) =>
|
|
|
+ collator.compare(a, b),
|
|
|
+ ),
|
|
|
+ ]}
|
|
|
+ {...(props.isLoading
|
|
|
+ ? { isPending: true, buttonLabel: "Status" }
|
|
|
+ : {
|
|
|
+ show: (value) => (value === "" ? "All" : value),
|
|
|
+ placement: "bottom end",
|
|
|
+ buttonLabel: props.status === "" ? "Status" : props.status,
|
|
|
+ selectedKey: props.status,
|
|
|
+ onSelectionChange: props.onStatusChange,
|
|
|
+ })}
|
|
|
+ />
|
|
|
+ <SearchInput
|
|
|
+ size="sm"
|
|
|
+ width={60}
|
|
|
+ placeholder={searchPlaceholder}
|
|
|
+ {...(props.isLoading
|
|
|
+ ? { isPending: true, isDisabled: true }
|
|
|
+ : {
|
|
|
+ value: props.search,
|
|
|
+ onChange: props.onSearchChange,
|
|
|
+ })}
|
|
|
+ />
|
|
|
+ <SingleToggleGroup
|
|
|
+ {...(!props.isLoading && {
|
|
|
+ selectedKey: props.showQuality ? "quality" : "prices",
|
|
|
+ onSelectionChange: (newValue) => {
|
|
|
+ props.setShowQuality(newValue === "quality");
|
|
|
+ },
|
|
|
+ })}
|
|
|
+ items={[
|
|
|
+ { id: "prices", children: "Prices" },
|
|
|
+ { id: "quality", children: "Quality" },
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+ </>
|
|
|
+ }
|
|
|
+ {...(!props.isLoading && {
|
|
|
+ footer: (
|
|
|
+ <Paginator
|
|
|
+ numPages={props.numPages}
|
|
|
+ currentPage={props.page}
|
|
|
+ onPageChange={props.onPageChange}
|
|
|
+ pageSize={props.pageSize}
|
|
|
+ onPageSizeChange={props.onPageSizeChange}
|
|
|
+ pageSizeOptions={[10, 20, 30, 40, 50]}
|
|
|
+ mkPageLink={props.mkPageLink}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ })}
|
|
|
+ >
|
|
|
+ <Table
|
|
|
+ label={label}
|
|
|
+ fill
|
|
|
+ rounded
|
|
|
+ stickyHeader={rootStyles.headerHeight}
|
|
|
+ columns={[
|
|
|
+ {
|
|
|
+ id: "name",
|
|
|
+ name: "NAME / ID",
|
|
|
+ alignment: "left",
|
|
|
+ isRowHeader: true,
|
|
|
+ loadingSkeleton: nameLoadingSkeleton,
|
|
|
+ allowsSorting: true,
|
|
|
+ },
|
|
|
+ ...otherColumns(props),
|
|
|
+ {
|
|
|
+ id: "status",
|
|
|
+ width: 20,
|
|
|
+ name: (
|
|
|
+ <>
|
|
|
+ STATUS
|
|
|
+ <Explain size="xs" title="Status">
|
|
|
+ A publisher{"'"}s feed have one of the following statuses:
|
|
|
+ <ul>
|
|
|
+ <li>
|
|
|
+ <b>Active</b> feeds have better than 50% uptime over the
|
|
|
+ last day
|
|
|
+ </li>
|
|
|
+ <li>
|
|
|
+ <b>Inactive</b> feeds have worse than 50% uptime over the
|
|
|
+ last day
|
|
|
+ </li>
|
|
|
+ <li>
|
|
|
+ <b>Unranked</b> feeds have not yet been evaluated by Pyth
|
|
|
+ </li>
|
|
|
+ </ul>
|
|
|
+ {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
|
|
|
+ </Explain>
|
|
|
+ </>
|
|
|
+ ),
|
|
|
+ alignment: "right",
|
|
|
+ allowsSorting: true,
|
|
|
+ },
|
|
|
+ ]}
|
|
|
+ {...(props.isLoading
|
|
|
+ ? { isLoading: true }
|
|
|
+ : {
|
|
|
+ rows: props.rows,
|
|
|
+ sortDescriptor: props.sortDescriptor,
|
|
|
+ onSortChange: props.onSortChange,
|
|
|
+ emptyState: (
|
|
|
+ <NoResults
|
|
|
+ query={props.search}
|
|
|
+ onClearSearch={() => {
|
|
|
+ props.onSearchChange("");
|
|
|
+ props.onStatusChange("");
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ ),
|
|
|
+ })}
|
|
|
+ />
|
|
|
+ </Card>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+const otherColumns = ({
|
|
|
+ metricsTime,
|
|
|
+ ...props
|
|
|
+}: { metricsTime?: Date | undefined } & (
|
|
|
+ | { isLoading: true }
|
|
|
+ | { isLoading?: false; showQuality: boolean }
|
|
|
+)) => {
|
|
|
+ if (props.isLoading) {
|
|
|
+ return [];
|
|
|
+ } else {
|
|
|
+ return props.showQuality
|
|
|
+ ? [
|
|
|
+ {
|
|
|
+ id: "uptimeScore",
|
|
|
+ width: 20,
|
|
|
+ name: (
|
|
|
+ <>
|
|
|
+ UPTIME SCORE
|
|
|
+ <Explain size="xs" title="Uptime">
|
|
|
+ <p>
|
|
|
+ Uptime is the percentage of time that a publisher{"'"}s feed
|
|
|
+ is available and active.
|
|
|
+ </p>
|
|
|
+ {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
|
|
|
+ <Button
|
|
|
+ href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking#uptime-1"
|
|
|
+ size="xs"
|
|
|
+ variant="outline"
|
|
|
+ target="_blank"
|
|
|
+ >
|
|
|
+ Read more
|
|
|
+ </Button>
|
|
|
+ </Explain>
|
|
|
+ </>
|
|
|
+ ),
|
|
|
+ alignment: "center" as const,
|
|
|
+ allowsSorting: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "deviationScore",
|
|
|
+ width: 20,
|
|
|
+ name: (
|
|
|
+ <>
|
|
|
+ DEVIATION SCORE
|
|
|
+ <Explain size="xs" title="Deviation">
|
|
|
+ <p>
|
|
|
+ Deviation measures how close a publisher{"'"}s price is to
|
|
|
+ what Pyth believes to be the true market price.
|
|
|
+ </p>
|
|
|
+ {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
|
|
|
+ <Button
|
|
|
+ href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking#price-deviation-1"
|
|
|
+ size="xs"
|
|
|
+ variant="outline"
|
|
|
+ target="_blank"
|
|
|
+ >
|
|
|
+ Read more
|
|
|
+ </Button>
|
|
|
+ </Explain>
|
|
|
+ </>
|
|
|
+ ),
|
|
|
+ alignment: "center" as const,
|
|
|
+ allowsSorting: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "stalledScore",
|
|
|
+ width: 20,
|
|
|
+ name: (
|
|
|
+ <>
|
|
|
+ STALLED SCORE
|
|
|
+ <Explain size="xs" title="Stalled">
|
|
|
+ <p>
|
|
|
+ A feed is considered stalled if it is publishing the same
|
|
|
+ value repeatedly for the price. This score component is
|
|
|
+ reduced each time a feed is stalled.
|
|
|
+ </p>
|
|
|
+ {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
|
|
|
+ <Button
|
|
|
+ href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking#lack-of-stalled-prices-1"
|
|
|
+ size="xs"
|
|
|
+ variant="outline"
|
|
|
+ target="_blank"
|
|
|
+ >
|
|
|
+ Read more
|
|
|
+ </Button>
|
|
|
+ </Explain>
|
|
|
+ </>
|
|
|
+ ),
|
|
|
+ alignment: "center" as const,
|
|
|
+ allowsSorting: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: "score",
|
|
|
+ name: (
|
|
|
+ <>
|
|
|
+ FINAL SCORE
|
|
|
+ <Explain size="xs" title="Uptime">
|
|
|
+ The final score is calculated by combining the three score
|
|
|
+ components as follows:
|
|
|
+ <ul>
|
|
|
+ <li>
|
|
|
+ <b>Uptime Score</b> (40% weight)
|
|
|
+ </li>
|
|
|
+ <li>
|
|
|
+ <b>Deviation Score</b> (40% weight)
|
|
|
+ </li>
|
|
|
+ <li>
|
|
|
+ <b>Stalled Score</b> (20% weight)
|
|
|
+ </li>
|
|
|
+ </ul>
|
|
|
+ {metricsTime && <EvaluationTime scoreTime={metricsTime} />}
|
|
|
+ <Button
|
|
|
+ href="https://docs.pyth.network/home/oracle-integrity-staking/publisher-quality-ranking"
|
|
|
+ size="xs"
|
|
|
+ variant="outline"
|
|
|
+ target="_blank"
|
|
|
+ >
|
|
|
+ Read more
|
|
|
+ </Button>
|
|
|
+ </Explain>
|
|
|
+ </>
|
|
|
+ ),
|
|
|
+ alignment: "left" as const,
|
|
|
+ width: SCORE_WIDTH,
|
|
|
+ loadingSkeleton: <Score isLoading width={SCORE_WIDTH} />,
|
|
|
+ allowsSorting: true,
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ : [
|
|
|
+ { id: "slot", name: "SLOT", alignment: "left" as const, width: 40 },
|
|
|
+ { id: "price", name: "PRICE", alignment: "left" as const, width: 40 },
|
|
|
+ {
|
|
|
+ id: "confidence",
|
|
|
+ name: "CONFIDENCE INTERVAL",
|
|
|
+ alignment: "left" as const,
|
|
|
+ width: 50,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+ }
|
|
|
+};
|