From 16428606aa02e3e089feb4d05cfeda074011fc20 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 1 Jun 2023 00:51:09 -0400 Subject: [PATCH 01/24] Wrap up baseline analytics & integrations --- components/Button.tsx | 150 + components/ErrorBoundary.tsx | 43 + components/Gradient2Button.tsx | 36 + components/LayoutBox.tsx | 37 + components/ProgressBar.tsx | 36 + components/RealTimeStats.tsx | 75 + components/analytics/DefaultChartHeader.tsx | 17 + components/analytics/DurationFilter.tsx | 33 + components/analytics/MovingAverageFilter.tsx | 21 + components/analytics/index.tsx | 3 + components/index.ts | 11 + components/layouts/TwoColumnLayout.tsx | 350 + constants/protocolMapping.ts | 6 + hooks/analytics/useAPYChart.ts | 59 + hooks/analytics/useMarketshareChart.ts | 62 + hooks/analytics/useProtocolRevenueChart.ts | 131 + hooks/analytics/useSupplyDistributionChart.ts | 89 + hooks/analytics/useTotalSupplyChart.ts | 63 + lib/dune/error.ts | 6 + lib/dune/index.ts | 240 + lib/dune/queryParameter.ts | 34 + lib/dune/types.ts | 62 + lib/dune/utils.ts | 22 + package.json | 12 +- pages/_app.tsx | 67 +- pages/analytics/collateral.tsx | 307 + pages/analytics/health-monitoring.tsx | 183 + pages/analytics/index.tsx | 278 + pages/analytics/protocol-revenue.tsx | 154 + pages/analytics/strategies.tsx | 255 + pages/api/analytics/charts/apy.ts | 74 + pages/api/analytics/charts/protocolRevenue.ts | 81 + pages/api/analytics/charts/totalSupply.ts | 74 + pages/api/dune.ts | 24 + public/images/ext-link-white.svg | 9 + public/images/gas.svg | 19 + utils/analytics.ts | 263 + yarn.lock | 7035 +++++++++++------ 38 files changed, 7880 insertions(+), 2541 deletions(-) create mode 100644 components/Button.tsx create mode 100644 components/ErrorBoundary.tsx create mode 100644 components/Gradient2Button.tsx create mode 100644 components/LayoutBox.tsx create mode 100644 components/ProgressBar.tsx create mode 100644 components/RealTimeStats.tsx create mode 100644 components/analytics/DefaultChartHeader.tsx create mode 100644 components/analytics/DurationFilter.tsx create mode 100644 components/analytics/MovingAverageFilter.tsx create mode 100644 components/analytics/index.tsx create mode 100644 components/layouts/TwoColumnLayout.tsx create mode 100644 hooks/analytics/useAPYChart.ts create mode 100644 hooks/analytics/useMarketshareChart.ts create mode 100644 hooks/analytics/useProtocolRevenueChart.ts create mode 100644 hooks/analytics/useSupplyDistributionChart.ts create mode 100644 hooks/analytics/useTotalSupplyChart.ts create mode 100644 lib/dune/error.ts create mode 100644 lib/dune/index.ts create mode 100644 lib/dune/queryParameter.ts create mode 100644 lib/dune/types.ts create mode 100644 lib/dune/utils.ts create mode 100644 pages/analytics/collateral.tsx create mode 100644 pages/analytics/health-monitoring.tsx create mode 100644 pages/analytics/index.tsx create mode 100644 pages/analytics/protocol-revenue.tsx create mode 100644 pages/analytics/strategies.tsx create mode 100644 pages/api/analytics/charts/apy.ts create mode 100644 pages/api/analytics/charts/protocolRevenue.ts create mode 100644 pages/api/analytics/charts/totalSupply.ts create mode 100644 pages/api/dune.ts create mode 100644 public/images/ext-link-white.svg create mode 100644 public/images/gas.svg create mode 100644 utils/analytics.ts diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..1f3ab95 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,150 @@ +import React, { useCallback } from "react"; +import classNames from "classnames"; + +interface Props extends React.HTMLProps { + children?: string | React.ReactNode; + prepend?: React.ReactNode; + append?: React.ReactNode; + buttonSize?: + | "base" + | "xxs" + | "xs" + | "sm" + | "md" + | "lg" + | "xl" + | "2xl" + | "3xl" + | "4xl" + | "5xl" + | "6xl"; + palette?: + | "default" + | "highlight" + | "orange" + | "dimmed" + | "white" + | "red" + | "primary" + | "error" + | "blue" + | "success" + | "balance" + | "outline" + | "deposit"; + className?: string; + type?: "button" | "submit" | "reset"; + backgroundColor?: string; +} + +type PaletteState = { + backgroundColor?: string; + borderColor?: string; + color?: string; +}; + +type Palette = { + blur: PaletteState; + hover: PaletteState; + disabled: PaletteState; +}; + +type Palettes = { + [key: string]: Palette; +}; + +const Palettes: Palettes = { + default: { + blur: { + backgroundColor: "bg-gradient2", + borderColor: "border-none", + color: "text-origin-white", + }, + hover: { + backgroundColor: "hover:bg-dark", + borderColor: "hover:border-dark", + color: "hover:text-[#FCFCFC]", + }, + disabled: { + backgroundColor: "disabled:!bg-dark", + borderColor: "disabled:!border-dark", + color: "disabled:!text-[#FCFCFC]", + }, + }, +}; + +const Button: React.ForwardRefRenderFunction = ( + { + children, + prepend, + append, + className, + buttonSize = "base", + palette = "default", + backgroundColor, + type = "button", + href, + ...restOfProps + }, + ref +) => { + const getButtonSize = useCallback((buttonSize) => { + const classList = []; + switch (buttonSize) { + case "sm": + classList.push("h-[40px] px-6 text-sm"); + break; + case "lg": + classList.push("h-[64px] px-12 text-lg"); + break; + default: + classList.push("h-[56px] px-10 text-base font-light"); + } + return classList.join(" "); + }, []); + + const getClassName = useCallback((palette = "default") => { + const classList = []; + // Handle palette + switch (palette) { + default: + classList.push( + Palettes[palette]?.blur?.backgroundColor, + Palettes[palette]?.blur?.borderColor, + Palettes[palette]?.blur?.color, + Palettes[palette]?.hover?.backgroundColor, + Palettes[palette]?.hover?.borderColor, + Palettes[palette]?.hover?.color, + Palettes[palette]?.disabled?.backgroundColor, + Palettes[palette]?.disabled?.borderColor, + Palettes[palette]?.disabled?.color + ); + } + return classList.join(" "); + }, []); + + return ( + + ); +}; + +export default React.forwardRef(Button); diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx new file mode 100644 index 0000000..079ef30 --- /dev/null +++ b/components/ErrorBoundary.tsx @@ -0,0 +1,43 @@ +import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"; +import { Typography } from "@originprotocol/origin-storybook"; +import Button from "./Button"; + +const fallbackRender = ({ resetErrorBoundary }) => { + return ( +
+ + Ooops... + + + Sorry, an error occurred while loading the view. + +
+ +
+
+ ); +}; + +const logError = (error: Error, info: { componentStack: string }) => { + // Do something with the error, e.g. log to an external API + console.error(error); +}; + +const ErrorBoundary = ({ children }) => ( + + {children} + +); + +export default ErrorBoundary; diff --git a/components/Gradient2Button.tsx b/components/Gradient2Button.tsx new file mode 100644 index 0000000..76c19fe --- /dev/null +++ b/components/Gradient2Button.tsx @@ -0,0 +1,36 @@ +import { PropsWithChildren } from "react"; +import { twMerge } from "tailwind-merge"; + +interface Gradient2ButtonProps { + onClick?: () => void; + className?: string; + outerDivClassName?: string; +} + +const Gradient2Button = ({ + onClick, + className, + outerDivClassName, + children, +}: PropsWithChildren) => { + return ( +
+ +
+ ); +}; + +export default Gradient2Button; diff --git a/components/LayoutBox.tsx b/components/LayoutBox.tsx new file mode 100644 index 0000000..fc822bb --- /dev/null +++ b/components/LayoutBox.tsx @@ -0,0 +1,37 @@ +import cx from "classnames"; +import { TailSpin } from "react-loader-spinner"; + +const LayoutBox = ({ + className = "", + loadingClassName = "", + isLoading = false, + children, +}) => ( +
+ {isLoading && ( +
+ +
+ )} + {children} +
+); + +export default LayoutBox; diff --git a/components/ProgressBar.tsx b/components/ProgressBar.tsx new file mode 100644 index 0000000..731775e --- /dev/null +++ b/components/ProgressBar.tsx @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +import cx from "classnames"; + +const ProgressBar = ({ + numerator, + color, + denominator = 100, + loadDelay = 300, + className = "", +}) => { + const [value, setValue] = useState({ numerator: 0, denominator }); + useEffect(() => { + setTimeout(() => { + setValue({ + numerator, + denominator, + }); + }, loadDelay); + }, []); + return ( +
+
+
+ ); +}; + +export default ProgressBar; diff --git a/components/RealTimeStats.tsx b/components/RealTimeStats.tsx new file mode 100644 index 0000000..e9d3f28 --- /dev/null +++ b/components/RealTimeStats.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import { useFeeData } from "wagmi"; +import { ethers } from "ethers"; +import Image from "next/image"; +import { Typography } from "@originprotocol/origin-storybook"; +import { formatCurrency } from "../utils/math"; + +const RealTimeStats = () => { + const [ogv, setOgv] = useState(0); + const { data, isError, isLoading } = useFeeData(); + + const gwei = + !isLoading && !isError + ? parseFloat( + ethers.utils.formatUnits(data?.formatted?.gasPrice || 0, "gwei") + )?.toFixed(2) + : 0; + + // useEffect(() => { + // (async function () { + // try { + // const data = await fetch( + // "https://api.coingecko.com/api/v3/simple/price?ids=origin-dollar-governance&vs_currencies=usd&include_market_cap=true&include_24hr_change=true&precision=full" + // ).then((res) => res.json()); + // console.log(data); + // } catch (e) { + // console.log(e); + // } + // })(); + // }, []); + + const ousd = "$1.0001"; + // const ogv = "$0.0055"; + + return ( +
+
+
+ Gas station icon + + {gwei} + +
+
+ {/*
*/} + {/*
*/} + {/* */} + {/* */} + {/* {ousd}*/} + {/* */} + {/*
*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/* OGV icon*/} + {/* */} + {/* {formatCurrency(ogv, 2)}*/} + {/* */} + {/*
*/} + {/*
*/} +
+ ); +}; + +export default RealTimeStats; diff --git a/components/analytics/DefaultChartHeader.tsx b/components/analytics/DefaultChartHeader.tsx new file mode 100644 index 0000000..0b4bacd --- /dev/null +++ b/components/analytics/DefaultChartHeader.tsx @@ -0,0 +1,17 @@ +import { Typography } from "@originprotocol/origin-storybook"; + +const DefaultChartHeader = ({ title, display, date }) => { + return ( +
+ + {title} + + {display} + + {date} + +
+ ); +}; + +export default DefaultChartHeader; diff --git a/components/analytics/DurationFilter.tsx b/components/analytics/DurationFilter.tsx new file mode 100644 index 0000000..a3d9e9e --- /dev/null +++ b/components/analytics/DurationFilter.tsx @@ -0,0 +1,33 @@ +import cx from "classnames"; +import { durationOptions } from "../../utils/analytics"; + +const DurationFilter = ({ value, onChange }) => ( +
+ {durationOptions.map((duration) => { + const isActiveDuration = value === duration.value; + return ( + + ); + })} +
+); + +export default DurationFilter; diff --git a/components/analytics/MovingAverageFilter.tsx b/components/analytics/MovingAverageFilter.tsx new file mode 100644 index 0000000..6871e29 --- /dev/null +++ b/components/analytics/MovingAverageFilter.tsx @@ -0,0 +1,21 @@ +import { typeOptions } from "../../utils/analytics"; + +const MovingAverageFilter = ({ value, onChange }) => { + return ( + + ); +}; + +export default MovingAverageFilter; diff --git a/components/analytics/index.tsx b/components/analytics/index.tsx new file mode 100644 index 0000000..5a89f73 --- /dev/null +++ b/components/analytics/index.tsx @@ -0,0 +1,3 @@ +export { default as DefaultChartHeader } from "./DefaultChartHeader"; +export { default as DurationFilter } from "./DurationFilter"; +export { default as MovingAverageFilter } from "./MovingAverageFilter"; diff --git a/components/index.ts b/components/index.ts index e61b3bb..f278329 100644 --- a/components/index.ts +++ b/components/index.ts @@ -1,3 +1,6 @@ +export { default as Image } from "next/image"; +export { default as Link } from "next/link"; + export { default as Footer } from "./Footer"; export { default as Section } from "./Section"; export { default as GradientButton } from "./GradientButton"; @@ -5,5 +8,13 @@ export { default as Article } from "./Article"; export { default as News } from "./News"; export { default as Header } from "./Header"; +export { default as Button } from "./Button"; +export { default as LayoutBox } from "./LayoutBox"; +export { default as TwoColumnLayout } from "./layouts/TwoColumnLayout"; +export { default as Gradient2Button } from "./Gradient2Button"; +export { default as ProgressBar } from "./ProgressBar"; +export { default as RealTimeStats } from "./RealTimeStats"; +export { default as ErrorBoundary } from "./ErrorBoundary"; + export * from "./homepage"; export * from "./strapi"; diff --git a/components/layouts/TwoColumnLayout.tsx b/components/layouts/TwoColumnLayout.tsx new file mode 100644 index 0000000..c97755e --- /dev/null +++ b/components/layouts/TwoColumnLayout.tsx @@ -0,0 +1,350 @@ +import { useMemo, useEffect, useState, useContext } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import dynamic from "next/dynamic"; +import cx from "classnames"; +import { capitalize } from "lodash"; +import { Typography } from "@originprotocol/origin-storybook"; +import { AnimatePresence, motion, useCycle } from "framer-motion"; +import { useSwipeable } from "react-swipeable"; +import Button from "../Button"; +import transformLinks from "../../utils/transformLinks"; +import { useRouter } from "next/router"; +import { NavigationContext } from "../../pages/_app"; + +const RealTimeStats = dynamic(() => import("../RealTimeStats"), { + ssr: false, +}); + +const partsToHref = (parts, index) => `/${parts.slice(0, index).join("/")}`; + +const partToLabel = (part) => capitalize(part?.replace(/-/g, " ")); + +const noop = () => {}; + +const NavigationSidebar = ({ + pathname, + linkHeight = 52, + navLinks, + onClickLink, +}) => ( +
+ +
+); + +const Breadcrumbs = ({ pathname }) => { + const parts: string[] = useMemo(() => { + if (pathname === "/analytics") { + return ["analytics", "overview"]; + } + return pathname?.replace("/", "").split("/"); + }, [pathname]); + + return ( +
    + {parts.map((part, index) => { + const isLast = index === parts?.length - 1; + const href = partsToHref(parts, index - 1); + const label = partToLabel(part); + return isLast ? ( + + / + {label} + + ) : ( + + {label} + + ); + })} +
+ ); +}; + +const MainNavigation = ({ links, onClickLink = noop }) => { + return ( + + ); +}; + +const NavigationDivider = () => ( +
+); + +const AnalyticsNavigation = ({ + links, + currentPathname, + onClickLink = noop, +}) => ( + +); + +const MobileNavigation = ({ links, subLinks, currentPathname }) => { + const [isMenuOpen, cycleOpen] = useCycle(false, true); + + useEffect(() => { + if (isMenuOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = "auto"; + } + }, [isMenuOpen]); + + const handlers = useSwipeable({ + onSwipedLeft: () => cycleOpen(), + }); + + return ( + <> +
+ {isMenuOpen ? ( +
+ ) : ( + + Origin ether logo + + )} + +
+ + {isMenuOpen && ( + <> +
cycleOpen()} + /> + + +
+ + { + cycleOpen(); + }} + /> +
+ +
+ { + cycleOpen(); + }} + /> +
+
+
+ + )} + + + ); +}; + +const DesktopSidebarNavigation = ({ + links, + subLinks, + sidebarWidth, + currentPathname, +}) => { + return ( +
+
+ + Origin ether logo + + + + +
+
+ ); +}; + +const TwoColumnLayout = ({ sidebarWidth = 316, children }) => { + const { links } = useContext(NavigationContext); + const { pathname } = useRouter(); + + const analyticsLinks = [ + { + label: "Overview", + href: "/analytics", + enabled: true, + }, + { + label: "Collateral", + href: "/analytics/collateral", + enabled: true, + }, + { + label: "Supply", + href: "/analytics/supply", + enabled: false, + }, + { + label: "Holders", + href: "/analytics/holders", + enabled: false, + }, + { + label: "Protocol revenue", + href: "/analytics/protocol-revenue", + enabled: true, + }, + { + label: "Strategies", + href: "/analytics/strategies", + enabled: true, + }, + { + label: "Dripper", + href: "/analytics/dripper", + enabled: false, + }, + { + label: "Health monitoring", + href: "/analytics/health-monitoring", + enabled: false, + }, + ].filter((link) => link.enabled); + + return ( + <> +
+ + +
+
+ +
+ +
+
+ {children} +
+
+ + ); +}; + +export default TwoColumnLayout; diff --git a/constants/protocolMapping.ts b/constants/protocolMapping.ts index ac184ba..f3db2cc 100644 --- a/constants/protocolMapping.ts +++ b/constants/protocolMapping.ts @@ -1,29 +1,35 @@ const protocolMapping = { Convex: { + color: '#ff5a5a', image: "/images/convex-strategy.svg", description: "Convex allows liquidity providers and stakers to earn greater rewards from Curve, a stablecoin-centric automated market maker (AMM). OETH earns trading fees and protocol token incentives (both CRV and CVX). This strategy serves as an algorithmic market operations controller (AMO), which enables OETH to safely leverage its own deposits to multiply returns and maintain the pool's balance.", }, Lido: { + color: '#66c8ff', image: "/images/lido-strategy.svg", description: "Lido is a liquid staking solution for Ethereum that staking ETH without locking tokens or maintaining infrastructure. OETH holds stETH to earn staking rewards for participating in the Ethereum network and adds a layer of auto-compounding.", }, RocketPool: { + color: '#e4ae89', image: "/images/rocketpool-strategy.svg", description: "Rocket Pool is a community-owned, decentralized protocol that mints rETH, an interest-earning ETH wrapper. OETH holds rETH to earn yield and normalizes the accounting by distributing additional tokens directly to users' wallets.", }, Frax: { + color: '#e85bff', image: "/images/frax-strategy.svg", description: "Frax uses a two-token model to maximize yields earned from staking validators. OETH deposits frxETH to the Frax Ether staking contract and amplifies this yield further.", }, Vault: { + color: '#8c66fc', description: "When OETH is minted, collateral is deposited into the Origin Vault and held until the allocate function is called. This happens automatically for larger transactions, which are less impacted by increased gas costs.", }, Morpho: { + color: '#9bc3e9', image: "/images/morpho-strategy.svg", description: "Morpho adds a peer-to-peer layer on top of Compound and Aave allowing lenders and borrowers to be matched more efficiently with better interest rates. When no matching opportunity exists, funds flow directly through to the underlying protocol. OUSD supplies stablecoins to three of Morpho’s Compound markets to earn interest. Additional yield is generated from protocol token incentives, including both COMP (regularly sold for USDT) and MORPHO (currently locked).", diff --git a/hooks/analytics/useAPYChart.ts b/hooks/analytics/useAPYChart.ts new file mode 100644 index 0000000..2e864ea --- /dev/null +++ b/hooks/analytics/useAPYChart.ts @@ -0,0 +1,59 @@ +import { useQuery } from "react-query"; +import { useMemo, useState } from "react"; +import { borderFormatting, filterByDuration, formatDisplay } from "../../utils/analytics"; + +export const useAPYChart = () => { + const { data, isFetching } = useQuery(`/api/analytics/charts/apy`, { + initialData: { + labels: [], + datasets: [], + error: null, + }, + refetchOnWindowFocus: false, + keepPreviousData: true, + }); + + const [chartState, setChartState] = useState({ + duration: "all", + typeOf: "total", + }); + + const chartData = useMemo(() => { + if (data?.error) { + return null; + } + return formatDisplay( + filterByDuration( + { + labels: data?.labels, + datasets: data?.datasets?.reduce((acc, dataset) => { + if (!chartState?.typeOf || dataset.id === chartState?.typeOf) { + acc.push({ + ...dataset, + ...borderFormatting, + }); + } + return acc; + }, []), + }, + chartState?.duration + ) + ); + }, [JSON.stringify(data), chartState?.duration, chartState?.typeOf]); + + const onChangeFilter = (value) => { + setChartState((prev) => ({ + ...prev, + ...value, + })); + }; + + return [ + { + data: chartData, + filter: chartState, + isFetching, + }, + { onChangeFilter }, + ]; +}; diff --git a/hooks/analytics/useMarketshareChart.ts b/hooks/analytics/useMarketshareChart.ts new file mode 100644 index 0000000..2b3e42c --- /dev/null +++ b/hooks/analytics/useMarketshareChart.ts @@ -0,0 +1,62 @@ +import { useQuery } from "react-query"; +import { useMemo, useState } from "react"; +import { + borderFormatting, + createGradient, + filterByDuration, + formatDisplay, +} from "../../utils/analytics"; + +export const useMarketshareChart = () => { + const { data, isFetching } = useQuery( + "/api/analytics/charts/ousdMarketshare", + { + initialData: { + labels: [], + datasets: [], + error: null, + }, + refetchOnWindowFocus: false, + keepPreviousData: true, + } + ); + + const [chartState, setChartState] = useState({ + duration: "all", + }); + + const chartData = useMemo(() => { + if (data?.error) { + return null; + } + return formatDisplay( + filterByDuration( + { + labels: data?.labels, + datasets: data?.datasets?.map((dataset) => ({ + ...dataset, + ...borderFormatting, + borderColor: createGradient(["#8C66FC", "#0274F1"]), + })), + }, + chartState.duration + ) + ); + }, [JSON.stringify(data), chartState?.duration]); + + const onChangeFilter = (value) => { + setChartState((prev) => ({ + ...prev, + ...value, + })); + }; + + return [ + { + data: chartData, + filter: chartState, + isFetching, + }, + { onChangeFilter }, + ]; +}; diff --git a/hooks/analytics/useProtocolRevenueChart.ts b/hooks/analytics/useProtocolRevenueChart.ts new file mode 100644 index 0000000..c6450ed --- /dev/null +++ b/hooks/analytics/useProtocolRevenueChart.ts @@ -0,0 +1,131 @@ +import { useQuery } from "react-query"; +import { useMemo, useState } from "react"; +import { isMobile } from "react-device-detect"; +import { borderFormatting, filterByDuration, formatDisplay } from "../../utils/analytics"; + +export const useProtocolRevenueChart = () => { + const { data, isFetching } = useQuery( + `/api/analytics/charts/protocolRevenue`, + { + initialData: { + labels: [], + datasets: [], + error: null, + }, + refetchOnWindowFocus: false, + keepPreviousData: true, + } + ); + + const [chartState, setChartState] = useState({ + duration: "all", + typeOf: "total", + }); + + const baseData = useMemo(() => { + if (data?.error) { + return null; + } + return { + labels: data?.labels, + datasets: data?.datasets?.reduce((acc, dataset) => { + if (!chartState?.typeOf || dataset.id === chartState?.typeOf) { + acc.push({ + ...dataset, + ...borderFormatting, + }); + } + return acc; + }, []), + }; + }, [JSON.stringify(data)]); + + const chartData = useMemo(() => { + return baseData + ? formatDisplay(filterByDuration(baseData, chartState?.duration)) + : null; + }, [JSON.stringify(baseData), chartState?.duration, chartState?.typeOf]); + + const onChangeFilter = (value) => { + setChartState((prev) => ({ + ...prev, + ...value, + })); + }; + + return [ + { + data: chartData, + // @ts-ignore + aggregations: data?.aggregations || {}, + filter: chartState, + isFetching, + chartOptions: { + responsive: true, + aspectRatio: isMobile ? 1 : 3, + plugins: { + title: { + display: false, + }, + legend: { + display: false, + position: "bottom", + }, + tooltip: { + enabled: true, + }, + }, + interaction: { + mode: "nearest", + intersect: false, + axis: "x", + }, + scales: { + x: { + border: { + color: "#4d505e", + width: 0.5, + }, + grid: { + display: false, + }, + ticks: { + color: "#828699", + autoSkip: false, + maxRotation: 90, + minRotation: 0, + padding: 20, + callback: function (val, index) { + return ( + isMobile ? (index + 22) % 28 === 0 : (index + 8) % 14 === 0 + ) + ? this.getLabelForValue(val) + : null; + }, + }, + }, + y: { + border: { + display: false, + dash: [2, 4], + dashOffset: 1, + }, + grid: { + color: "#4d505e", + lineWidth: 0.5, + }, + beginAtZero: true, + position: "right", + ticks: { + color: "#828699", + callback: function (val) { + return val === 0 ? null : this.getLabelForValue(val); + }, + }, + }, + }, + }, + }, + { onChangeFilter }, + ]; +}; diff --git a/hooks/analytics/useSupplyDistributionChart.ts b/hooks/analytics/useSupplyDistributionChart.ts new file mode 100644 index 0000000..b4206bb --- /dev/null +++ b/hooks/analytics/useSupplyDistributionChart.ts @@ -0,0 +1,89 @@ +import { useMemo, useState } from "react"; +import { formatCurrency, formatPercentage } from "../../utils/math"; + +const mockData = [ + { + type: "rebasing", + label: "OUSD swap", + total: 14380104, + totalDisplay: `$${formatCurrency(14380104, 0)}`, + color: "#1A44B5", + }, + { + type: "rebasing", + label: "Other", + total: 14380104, + totalDisplay: `$${formatCurrency(14380104, 0)}`, + color: "#2A70F5", + }, + { + type: "nonRebasing", + label: "Curve", + total: 23705812, + totalDisplay: `$${formatCurrency(23705812, 0)}`, + color: "#4B3C6D", + }, + { + type: "nonRebasing", + label: "Uniswap v3 OUSD/USDT", + total: 16411187, + totalDisplay: `$${formatCurrency(16411187, 0)}`, + color: "#D72FC6", + }, + { + type: "nonRebasing", + label: "Other", + total: 16411187, + totalDisplay: `$${formatCurrency(16411187, 0)}`, + color: "#9951EF", + }, +]; + +export const useSupplyDistributionChart = () => { + const [chartState, setChartState] = useState({ + duration: "all", + typeOf: "total", + }); + + const chartData = useMemo(() => { + return { + labels: mockData.map((item) => item.label), + datasets: [ + { + label: "Current total Supply breakdown", + data: mockData.map((item) => item.total), + backgroundColor: mockData.map((item) => item.color), + borderWidth: 0, + hoverOffset: 50, + }, + ], + }; + }, []); + + const onChangeFilter = (value) => {}; + + return [ + { + data: chartData, + filter: chartState, + detailed: mockData, + isFetching: false, + chartOptions: { + responsive: true, + plugins: { + title: { + display: false, + }, + legend: { + display: false, + }, + tooltip: { + enabled: false, + }, + }, + cutout: "70%", + }, + }, + { onChangeFilter }, + ]; +}; diff --git a/hooks/analytics/useTotalSupplyChart.ts b/hooks/analytics/useTotalSupplyChart.ts new file mode 100644 index 0000000..89891ad --- /dev/null +++ b/hooks/analytics/useTotalSupplyChart.ts @@ -0,0 +1,63 @@ +import { useQuery } from "react-query"; +import { useMemo, useState } from "react"; +import { + borderFormatting, + createGradient, + filterByDuration, + formatDisplay, +} from "../../utils/analytics"; + +export const useTotalSupplyChart = () => { + const { data, isFetching } = useQuery("/api/analytics/charts/totalSupply", { + initialData: { + labels: [], + datasets: [], + }, + refetchOnWindowFocus: false, + keepPreviousData: true, + }); + + const [chartState, setChartState] = useState({ + duration: "all", + typeOf: "total", + }); + + const chartData = useMemo(() => { + return formatDisplay( + filterByDuration( + { + labels: data?.labels, + datasets: data?.datasets?.reduce((acc, dataset) => { + if (!chartState?.typeOf || dataset.id === chartState?.typeOf) { + acc.push({ + ...dataset, + ...borderFormatting, + borderWidth: 0, + backgroundColor: createGradient(["#8C66FC", "#0274F1"]), + fill: true, + }); + } + return acc; + }, []), + }, + chartState.duration + ) + ); + }, [JSON.stringify(data), chartState?.duration, chartState?.typeOf]); + + const onChangeFilter = (value) => { + setChartState((prev) => ({ + ...prev, + ...value, + })); + }; + + return [ + { + data: chartData, + filter: chartState, + isFetching, + }, + { onChangeFilter }, + ]; +}; diff --git a/lib/dune/error.ts b/lib/dune/error.ts new file mode 100644 index 0000000..948efb6 --- /dev/null +++ b/lib/dune/error.ts @@ -0,0 +1,6 @@ +export class DuneError extends Error { + constructor(msg: string) { + super(msg); + Object.setPrototypeOf(this, DuneError.prototype); + } +} diff --git a/lib/dune/index.ts b/lib/dune/index.ts new file mode 100644 index 0000000..c0ad61c --- /dev/null +++ b/lib/dune/index.ts @@ -0,0 +1,240 @@ +import Redis from "ioredis"; +import { DuneError } from "./error"; +import { QueryParameter } from "./queryParameter"; +import { + ExecutionState, + ExecutionResponse, + GetStatusResponse, + ResultsResponse, +} from "./types"; +export { toChartData } from "./utils"; + +console.debug = function () {}; + +export const jobsLookup = { + apy: { + queryId: 2520876, + expiresAfter: 86400, + }, + totalSupplyOETH: { + queryId: 2520878, + expiresAfter: 86400, + }, + protocolRevenue: { + queryId: 2520880, + expiresAfter: 86400, + }, + totalSupplyBreakdown: { + queryId: 2520879, + expiresAfter: 86400, + } +}; + +const BASE_URL = "https://api.dune.com/api/v1"; + +const TERMINAL_STATES = [ + ExecutionState.CANCELLED, + ExecutionState.COMPLETED, + ExecutionState.FAILED, +]; + +const logPrefix = "dune-client:"; + +const sleep = (seconds: number) => + new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + +const CACHE_READY_STATE = "ready"; + +class DuneClient { + apiKey: string; + + cacheClient; + + constructor(apiKey: string) { + this.apiKey = apiKey; + if (process.env.REDIS_URL) { + this.cacheClient = new Redis(process.env.REDIS_URL, { + tls: { + rejectUnauthorized: false, + }, + connectTimeout: 10000, + lazyConnect: true, + retryStrategy(times) { + if (times > 5) return null; + return Math.min(times * 500, 2000); + }, + }); + + this.cacheClient.on("ready", function () { + console.log("Cache connected for DUNE API"); + }); + } + } + + private async _checkCache(key) { + try { + return JSON.parse(await this.cacheClient.get(key)); + } catch (error) { + console.error( + logPrefix, + `caught unhandled response error ${JSON.stringify(error)}` + ); + return null; + } + } + + private async _handleResponse( + responsePromise: Promise + ): Promise { + const apiResponse = await responsePromise + .then((response) => { + if (!response.ok) { + console.error( + logPrefix, + `response error ${response.status} - ${response.statusText}` + ); + } + return response.json(); + }) + .catch((error) => { + console.error( + logPrefix, + `caught unhandled response error ${JSON.stringify(error)}` + ); + throw error; + }); + + if (apiResponse.error) { + console.error( + logPrefix, + `error contained in response ${JSON.stringify(apiResponse)}` + ); + if (apiResponse.error instanceof Object) { + throw new DuneError(apiResponse.error.type); + } else { + throw new DuneError(apiResponse.error); + } + } + + return apiResponse; + } + + private async _get(url: string): Promise { + console.debug(logPrefix, `GET received input url=${url}`); + const response = fetch(url, { + method: "GET", + headers: { + "x-dune-api-key": this.apiKey, + }, + }); + return this._handleResponse(response); + } + + private async _post(url: string, params?: QueryParameter[]): Promise { + console.debug( + logPrefix, + `POST received input url=${url}, params=${JSON.stringify(params)}` + ); + // Transform Query Parameter list into "dict" + const reducedParams = params?.reduce>( + (acc, { name, value }) => ({ ...acc, [name]: value }), + {} + ); + + const response = fetch(url, { + method: "POST", + body: JSON.stringify({ query_parameters: reducedParams || {} }), + headers: { + "x-dune-api-key": this.apiKey, + }, + }); + return this._handleResponse(response); + } + + async execute( + queryID: number, + parameters?: QueryParameter[] + ): Promise { + const response = await this._post( + `${BASE_URL}/query/${queryID}/execute`, + parameters + ); + console.debug(logPrefix, `execute response ${JSON.stringify(response)}`); + return response as ExecutionResponse; + } + + async getStatus(jobID: string): Promise { + const response: GetStatusResponse = await this._get( + `${BASE_URL}/execution/${jobID}/status` + ); + console.debug(logPrefix, `get_status response ${JSON.stringify(response)}`); + return response as GetStatusResponse; + } + + async getResult(jobID: string): Promise { + const key = `${BASE_URL}/execution/${jobID}/results`; + const response: ResultsResponse = await this._get(key); + console.debug(logPrefix, `get_result response ${JSON.stringify(response)}`); + return response as ResultsResponse; + } + + async cancelExecution(jobID: string): Promise { + const { success }: { success: boolean } = await this._post( + `${BASE_URL}/execution/${jobID}/cancel` + ); + return success; + } + + async refresh( + queryID: number, + parameters?: QueryParameter[], + pingFrequency: number = 5, + cacheExpiration: number = 21600 + ): Promise { + console.info( + logPrefix, + `refreshing query https://dune.com/queries/${queryID} with parameters ${JSON.stringify( + parameters + )}` + ); + const data = await this._checkCache(String(queryID)); + if (data) { + return data as ResultsResponse; + } else { + const { execution_id: jobID } = await this.execute(queryID, parameters); + let { state } = await this.getStatus(jobID); + while (!TERMINAL_STATES.includes(state)) { + console.info( + logPrefix, + `waiting for query execution ${jobID} to complete: current state ${state}` + ); + await sleep(pingFrequency); + state = (await this.getStatus(jobID)).state; + } + if (state === ExecutionState.COMPLETED) { + const result = await this.getResult(jobID); + // Store in cache by jobID and `cacheExpiration` + if (this.cacheClient.status === CACHE_READY_STATE) { + const cachedResultSet = JSON.stringify(result); + await this.cacheClient.set( + String(queryID), + cachedResultSet, + "EX", + cacheExpiration + ); + console.log( + logPrefix, + `get_result cached response for ${queryID}: ${cacheExpiration} seconds` + ); + } + return result; + } else { + const message = `refresh (execution ${jobID}) yields incomplete terminal state ${state}`; + console.error(logPrefix, message); + throw new DuneError(message); + } + } + } +} + +export default DuneClient; diff --git a/lib/dune/queryParameter.ts b/lib/dune/queryParameter.ts new file mode 100644 index 0000000..129c262 --- /dev/null +++ b/lib/dune/queryParameter.ts @@ -0,0 +1,34 @@ +enum ParameterType { + TEXT = "text", + NUMBER = "number", + DATE = "date", + ENUM = "enum", +} + +export class QueryParameter { + type: ParameterType; + value: string; + name: string; + + constructor(type: ParameterType, name: string, value: any) { + this.type = type; + this.value = value.toString(); + this.name = name; + } + + static text(name: string, value: string): QueryParameter { + return new QueryParameter(ParameterType.TEXT, name, value); + } + + static number(name: string, value: string | number): QueryParameter { + return new QueryParameter(ParameterType.NUMBER, name, value.toString()); + } + + static date(name: string, value: string | Date): QueryParameter { + return new QueryParameter(ParameterType.DATE, name, value.toString()); + } + + static enum(name: string, value: string): QueryParameter { + return new QueryParameter(ParameterType.ENUM, name, value.toString()); + } +} diff --git a/lib/dune/types.ts b/lib/dune/types.ts new file mode 100644 index 0000000..bc4d6e5 --- /dev/null +++ b/lib/dune/types.ts @@ -0,0 +1,62 @@ +export enum ExecutionState { + COMPLETED = "QUERY_STATE_COMPLETED", + EXECUTING = "QUERY_STATE_EXECUTING", + PENDING = "QUERY_STATE_PENDING", + CANCELLED = "QUERY_STATE_CANCELLED", + FAILED = "QUERY_STATE_FAILED", +} + +export interface ExecutionResponse { + execution_id: string; + state: ExecutionState; +} + +export interface TimeData { + submitted_at: Date; + execution_started_at?: Date; + execution_ended_at?: Date; + expires_at?: Date; + cancelled_at?: Date; +} + +export interface ResultMetadata { + column_names: string[]; + result_set_bytes: number; + total_row_count: number; + datapoint_count: number; + pending_time_millis: number; + execution_time_millis: number; +} + +export interface BaseStatusResponse extends TimeData { + execution_id: string; + query_id: number; +} + +export interface IncompleteStatusResponse extends BaseStatusResponse { + state: Exclude; + queue_position?: number; +} + +export interface CompleteStatusResponse extends BaseStatusResponse { + state: ExecutionState.COMPLETED; + queue_position?: number; + result_metadata: ResultMetadata; +} + +export type GetStatusResponse = + | IncompleteStatusResponse + | CompleteStatusResponse; + +export interface ExecutionResult { + rows: Record[]; + metadata: ResultMetadata; +} + +export interface ResultsResponse extends TimeData { + execution_id: string; + query_id: number; + state: ExecutionState; + // only present when state is COMPLETE + result?: ExecutionResult; +} diff --git a/lib/dune/utils.ts b/lib/dune/utils.ts new file mode 100644 index 0000000..fdac691 --- /dev/null +++ b/lib/dune/utils.ts @@ -0,0 +1,22 @@ +import { reduce } from "lodash"; + +export const toChartData = (rows, keys) => { + const initialState = reduce( + keys, + (acc, toKey) => { + acc[toKey] = []; + return acc; + }, + {} + ); + return rows?.reduce((acc, row) => { + Object.keys(keys).forEach((fromKey) => { + const toKey = keys[fromKey]; + acc[toKey]?.push(row[fromKey]); + }); + return acc; + }, initialState); +}; + +export const formatLabels = (labels) => + labels.map((d) => new Date(d).toString().slice(4, 10)); diff --git a/package.json b/package.json index 954007f..3e52438 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,14 @@ "@originprotocol/origin-storybook": "^0.27.13", "@sentry/nextjs": "^7.51.2", "chart.js": "^4.2.1", + "classnames": "^2.3.2", + "date-fns": "^2.29.3", "dompurify": "^3.0.2", + "dotenv": "^16.0.3", "ethers": "^5.7.2", + "framer-motion": "^10.6.0", "he": "^1.2.0", + "ioredis": "^5.3.1", "lodash": "^4.17.21", "moment": "^2.29.4", "next": "latest", @@ -25,12 +30,17 @@ "qs": "^6.11.1", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", + "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.3", + "react-loader-spinner": "^5.3.4", "react-minimal-pie-chart": "^8.4.0", "react-moment": "^1.1.3", "react-query": "^3.39.3", + "react-swipeable": "^7.0.0", "sanitize-html": "^2.10.0", - "tailwind-merge": "^1.12.0" + "tailwind-merge": "^1.12.0", + "wagmi": "^0.12.7" }, "devDependencies": { "@typechain/ethers-v5": "^10.2.0", diff --git a/pages/_app.tsx b/pages/_app.tsx index f57c044..1b5a86f 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,14 +1,16 @@ import "@originprotocol/origin-storybook/lib/styles.css"; -import "../styles/globals.css"; -import React, { createContext, useEffect } from "react"; +import React, { createContext, useEffect, useState } from "react"; import { useRouter } from "next/router"; import { AppProps } from "next/app"; import Script from "next/script"; import { QueryClient, QueryClientProvider } from "react-query"; import Head from "next/head"; +import { WagmiConfig, createClient, configureChains, mainnet } from "wagmi"; +import { publicProvider } from "wagmi/providers/public"; import { assetRootPath } from "../utils"; import { GTM_ID, pageview } from "../utils/gtm"; import { useContracts } from "../hooks"; +import "../styles/globals.css"; const defaultQueryFn = async ({ queryKey }) => { return await fetch(queryKey).then((res) => res.json()); @@ -27,9 +29,59 @@ export const GlobalContext = createContext({ siteName: "", }); +export const NavigationContext = createContext({ + links: [], +}); + +const { provider, webSocketProvider } = configureChains( + [mainnet], + [publicProvider()] +); + +const wagmiClient = createClient({ + autoConnect: false, + provider, + webSocketProvider, +}); + +const useNavigationLinks = () => { + const [links, setLinks] = useState([]); + + useEffect(() => { + (async function () { + try { + const { data } = await fetch("/api/navigation", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + params: { + populate: { + links: { + populate: "*", + }, + }, + }, + }), + }).then((res) => res.json()); + setLinks(data); + } catch (e) { + console.error(e); + } + })(); + }, []); + return [{ links }]; +}; + function MyApp({ Component, pageProps }: AppProps) { const router = useRouter(); + const [{ links }] = useNavigationLinks(); + + const getLayout = Component.getLayout || ((page) => page); + useContracts(); + useEffect(() => { router.events.on("routeChangeComplete", pageview); return () => { @@ -56,8 +108,15 @@ function MyApp({ Component, pageProps }: AppProps) { }} /> - {/* @ts-ignore */} - + + + {getLayout()} + + ); diff --git a/pages/analytics/collateral.tsx b/pages/analytics/collateral.tsx new file mode 100644 index 0000000..e0817a7 --- /dev/null +++ b/pages/analytics/collateral.tsx @@ -0,0 +1,307 @@ +import React, { useMemo, useState } from "react"; +import { useQuery } from "react-query"; +import Head from "next/head"; +import Link from "next/link"; +import { Typography } from "@originprotocol/origin-storybook"; +import { GetServerSideProps } from "next"; +import classnames from "classnames"; +import { last, map } from "lodash"; +import { Bar } from "react-chartjs-2"; +import { orderBy } from "lodash"; +import { + BarElement, + CategoryScale, + Chart as ChartJS, + LinearScale, +} from "chart.js"; +import { + ErrorBoundary, + LayoutBox, + Image, + TwoColumnLayout, +} from "../../components"; +import { DurationFilter } from "../../components/analytics"; +import { + aggregateCollateral, + backingTokens, + createGradient, +} from "../../utils/analytics"; +import { fetchAllocation, fetchCollateral } from "../../utils/api"; +import { + formatCurrency, + formatCurrencyAbbreviated, +} from "../../utils/math"; + +ChartJS.register(CategoryScale, LinearScale, BarElement); + +const CollateralAggregate = ({ data = [] }) => { + return ( +
+ {data.map(({ label, logoSrc, percentage, total }, index) => ( + 0 && index !== data.length - 1, // middle sections + "rounded-tl-none rounded-bl-none": index === data.length - 1, + })} + > +
+ {label} +
+ + {label} + + ${formatCurrency(total, 2)} + + {formatCurrency(percentage * 100, 2)}% + +
+
+
+ ))} +
+ ); +}; + +const CollateralChart = () => { + const { data, isFetching } = useQuery("/api/analytics/charts/totalSupply", { + initialData: { + labels: [], + datasets: [], + }, + refetchOnWindowFocus: false, + keepPreviousData: true, + }); + + const [chartState, setChartState] = useState({ + duration: "all", + typeOf: "total", + }); + + const chartData = useMemo(() => { + return { + labels: data?.labels, + datasets: data?.datasets?.reduce((acc, dataset) => { + if (!chartState?.typeOf || dataset.id === chartState?.typeOf) { + acc.push({ + ...dataset, + borderWidth: 0, + backgroundColor: createGradient(["#8C66FC", "#0274F1"]), + fill: true, + }); + } + return acc; + }, []), + }; + }, [JSON.stringify(data), chartState?.duration, chartState?.typeOf]); + + const labels = []; + + return ( + +
+
+ + Collateral + +
+
+
+ + DAI + + $14,380,104 + + 26.38% + +
+
+
+ + USDC + + $14,380,104 + + 26.38% + +
+
+
+ + USDT + + $14,380,104 + + 26.38% + +
+
+ + {last(chartData?.labels)} + +
+
+ { + setChartState((prev) => ({ + ...prev, + duration: duration || "all", + })); + }} + /> +
+
+
+ Math.random() * 1000), + backgroundColor: "rgb(255, 99, 132)", + }, + { + label: "Dataset 2", + data: labels.map(() => Math.random() * 1000), + backgroundColor: "rgb(75, 192, 192)", + }, + { + label: "Dataset 3", + data: labels.map(() => Math.random() * 1000), + backgroundColor: "rgb(53, 162, 235)", + }, + ], + }} + /> +
+ + ); +}; + +const CollateralPoolDistributions = ({ data = [] }) => { + return ( +
+ Collateral Distribution +
+ {data?.map( + ({ name, address, icon_file: iconFilename, total, holdings }) => ( + +
+ {name} + + Collateral + +
+ {map(holdings, (holdingTotal, token) => + backingTokens[token] ? ( +
+ {token} +
+ {token} + + + ${formatCurrencyAbbreviated(holdingTotal, 2)} + + External link + +
+
+ ) : null + )} +
+
+
+ ) + )} +
+
+ ); +}; + +const AnalyticsCollateral = ({ strategies, collateral }) => { + return ( + + + Analytics | Collateral + + +
+
+ +
+ {/*
*/} + {/* */} + {/*
*/} +
+ +
+
+
+ ); +}; + +export const getServerSideProps: GetServerSideProps = async (): Promise<{ + props; +}> => { + const [allocation, { collateral }] = await Promise.all([ + fetchAllocation(), + fetchCollateral(), + ]); + return { + props: { + strategies: orderBy(allocation?.strategies, "total", "desc"), + collateral: orderBy( + aggregateCollateral({ collateral, allocation }), + "total", + "desc" + ), + }, + }; +}; + +export default AnalyticsCollateral; + +AnalyticsCollateral.getLayout = (page, props) => ( + {page} +); diff --git a/pages/analytics/health-monitoring.tsx b/pages/analytics/health-monitoring.tsx new file mode 100644 index 0000000..b1f8c23 --- /dev/null +++ b/pages/analytics/health-monitoring.tsx @@ -0,0 +1,183 @@ +import { Typography } from "@originprotocol/origin-storybook"; +import Head from "next/head"; +import { + ErrorBoundary, + LayoutBox, + TwoColumnLayout, +} from "../../components"; + +const monitoring = { + macro: [ + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=10", + description: "", + }, + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=22", + description: "", + }, + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=24", + description: "", + }, + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=34", + description: "", + }, + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=32", + description: "", + }, + ], + statistics: [ + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=26", + description: "", + }, + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=2", + description: "", + }, + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=4", + description: "", + }, + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=28", + description: "", + }, + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=30", + description: "", + }, + ], + strategies: [ + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=8", + description: "", + }, + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=18", + description: "", + }, + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=6", + description: "", + }, + { + url: "https://ousd-dashboard.ogn.app/d-solo/0YIjaWh4z/main-dashboard?orgId=1&panelId=20", + description: "", + }, + ], +}; + +const AnalyticsHealthMonitoring = () => { + return ( + + + Analytics | Health Monitoring + +
+ + Monitoring the health of DEFI contracts that can affect OUSD strategy + health. + +
+ Macro situation +
+ {monitoring?.macro.map(({ url, description }) => ( +
+ +
+