diff --git a/nym-node-status-api/nym-node-status-ui/package.json b/nym-node-status-api/nym-node-status-ui/package.json index 610a288446..d738ed90dc 100644 --- a/nym-node-status-api/nym-node-status-ui/package.json +++ b/nym-node-status-api/nym-node-status-ui/package.json @@ -19,6 +19,7 @@ "@mui/x-charts": "^8.8.0", "@mui/x-date-pickers": "^7.29.4", "@tanstack/react-query": "^5.83.0", + "d3-array": "^3.2.4", "dayjs": "^1.11.13", "material-react-table": "^3.2.1", "next": "^15.4.1", @@ -30,6 +31,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@tanstack/react-query-devtools": "^5.83.0", + "@types/d3-array": "^3.2.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/nym-node-status-api/nym-node-status-ui/pnpm-lock.yaml b/nym-node-status-api/nym-node-status-ui/pnpm-lock.yaml index ab4a48acac..e42724493a 100644 --- a/nym-node-status-api/nym-node-status-ui/pnpm-lock.yaml +++ b/nym-node-status-api/nym-node-status-ui/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@tanstack/react-query': specifier: ^5.83.0 version: 5.83.0(react@19.0.0) + d3-array: + specifier: ^3.2.4 + version: 3.2.4 dayjs: specifier: ^1.11.13 version: 1.11.13 @@ -60,6 +63,9 @@ importers: '@tanstack/react-query-devtools': specifier: ^5.83.0 version: 5.83.0(@tanstack/react-query@5.83.0(react@19.0.0))(react@19.0.0) + '@types/d3-array': + specifier: ^3.2.2 + version: 3.2.2 '@types/node': specifier: ^20 version: 20.17.14 @@ -659,6 +665,9 @@ packages: '@tanstack/virtual-core@3.11.2': resolution: {integrity: sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} @@ -1723,6 +1732,8 @@ snapshots: '@tanstack/virtual-core@3.11.2': {} + '@types/d3-array@3.2.2': {} + '@types/d3-color@3.1.3': {} '@types/d3-delaunay@6.0.4': {} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/dvpn/GatewaysTable.tsx b/nym-node-status-api/nym-node-status-ui/src/app/dvpn/GatewaysTable.tsx new file mode 100644 index 0000000000..30a389cd80 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/dvpn/GatewaysTable.tsx @@ -0,0 +1,379 @@ +import type { DVpnGateway } from "@/client"; +import { ReverseScoreIcon, ScoreIcon } from "@/components/ScoreIcon"; +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { + IconButton, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, +} from "@mui/material"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { + type MRT_ColumnDef, + MaterialReactTable, + useMaterialReactTable, +} from "material-react-table"; +import { useMemo } from "react"; +import ReactCountryFlag from "react-country-flag"; + +dayjs.extend(duration); +dayjs.extend(relativeTime); + +const regionNamesInEnglish = new Intl.DisplayNames(["en"], { type: "region" }); + +const staleGatewayBinWidthMinutes = 15; + +interface StaleGatewayStats { + bins: number[]; + average: number; + sum: number; + count: number; +} + +export default function GatewaysTable() { + const { data, isError, isRefetching, isLoading, refetch } = + useDVpnGatewaysTransformed().query; + + const staleGateways = useMemo( + () => + (data || []).reduce( + (acc, g) => { + const last_updated_utc = g.last_probe + ? dayjs(g.last_probe.last_updated_utc) + : null; + if (!last_updated_utc) return acc; + const diff = dayjs().diff(last_updated_utc, "minutes"); + const bin = Math.floor(diff / staleGatewayBinWidthMinutes); + if (!acc.bins[bin]) { + acc.bins[bin] = 0; + } + acc.bins[bin] += 1; + acc.sum += diff; + acc.count += 1; + acc.average = acc.sum / acc.count; + return acc; + }, + { + bins: [], + average: 0, + sum: 0, + count: 0, + } as StaleGatewayStats, + ), + [data], + ); + + const columns = useMemo[]>( + //column definitions... + () => [ + { + accessorKey: "name", + header: "Name", + }, + { + accessorKey: "identity_key", + header: "Identity Key", + Cell: ({ cell }) => ( + {cell.getValue()?.slice(0, 8)}... + ), + }, + { + accessorKey: "location.two_letter_iso_country_code", + header: "Country", + Cell: ({ cell }) => { + const value = cell.getValue(); + return ( + <> + {value} + + {regionNamesInEnglish.of(value)} + + + ); + }, + }, + { + accessorKey: "location.region", + header: "City / Region", + Cell: ({ row }) => { + return ( + <> + + {(row.original.location as any).city}/ + {(row.original.location as any).region} + + + ); + }, + }, + { + accessorKey: "performance_v2.score", + width: 20, + header: "Score", + Cell: ({ cell }) => { + const value = cell.getValue(); + return ( + <> + + + {value || "-"} + + + ); + }, + }, + { + accessorKey: "performance_v2.load", + width: 20, + header: "Load", + Cell: ({ cell }) => { + const value = cell.getValue(); + return ( + <> + + + {value || "-"} + + + ); + }, + }, + { + accessorKey: "extra.downloadSpeedMBPerSec", + header: "Download Speed ipv4 (MB/sec)", + Cell: ({ renderedCellValue, cell }) => { + if (!cell.getValue()) { + return null; + } + return ( + + {renderedCellValue} MB/sec + + ); + }, + }, + { + accessorKey: "extra.downloadSpeedIpv6MBPerSec", + header: "Download Speed ipv6 (MB/sec)", + Cell: ({ renderedCellValue, cell }) => { + if (!cell.getValue()) { + return null; + } + return ( + + {renderedCellValue} MB/sec + + ); + }, + }, + { + accessorKey: "last_probe.outcome.wg.ping_ips_performance_v4", + header: "Probe pings (IPV4)", + Cell: ({ cell }) => { + const value = Math.floor( + Number.parseFloat(cell.getValue() || "0") * 100, + ); + return ( + <> + + {value}% + + + ); + }, + }, + { + accessorKey: "performance_v2.uptime_percentage_last_24_hours", + width: 20, + header: "Uptime", + Cell: ({ cell, row }) => { + const value: number = + ((row.original as any).performance_v2 + ?.uptime_percentage_last_24_hours || 0) * 100; + // const value = Math.floor(Number.parseFloat(cell.getValue()) * 100); + return ( + <> + + {value}% + + + ); + }, + }, + { + accessorKey: "last_probe.outcome.wg.can_query_metadata_v4", + header: "Can query metadata?", + Cell: ({ cell }) => { + const wg = cell.row.original.last_probe?.outcome.wg as any; + const can_query_metadata_v4 = wg?.can_query_metadata_v4; + return ( + <> + + {can_query_metadata_v4 === null || + (can_query_metadata_v4 === undefined && -)} + {can_query_metadata_v4 === true && } + {can_query_metadata_v4 === false && } + + + ); + }, + }, + { + accessorKey: "last_probe.last_updated_utc", + header: "Last Probed At", + Cell: ({ cell }) => { + const parsed = dayjs(cell.getValue()); + return ( + +
+ {parsed.format()} +
+
+ ({parsed.fromNow()}) +
+
+ ); + }, + }, + { + id: "last_probe_age", + accessorKey: "last_probe.last_updated_utc", + header: "Last Probed Age", + Cell: ({ cell, row }) => { + const value = row.original.last_probe?.last_updated_utc; + if (!value) { + return "-"; + } + const parsed = dayjs(value); + const age = dayjs().diff(parsed, "minutes"); + return <>{age} minutes; + }, + }, + { + accessorKey: "build_information.build_version", + header: "Version", + }, + ], + [], + //end + ); + + const table = useMaterialReactTable({ + columns, + data: data || [], + initialState: { + showColumnFilters: true, + density: "compact", + pagination: { + pageIndex: 0, + pageSize: 100, + }, + }, + muiToolbarAlertBannerProps: isError + ? { + color: "error", + children: "Error loading data", + } + : undefined, + renderTopToolbarCustomActions: () => ( + + refetch()}> + + + + ), + rowCount: data?.length || 0, + state: { + isLoading, + showAlertBanner: isError, + showProgressBars: isRefetching, + }, + }); + + return ( + <> + +

Gateway probe age

+ + Average age is {Math.round(staleGateways.average * 10) / 10} minutes old + + + + + + Age + Gateways + + + + {staleGateways.bins.map((r, i) => ( + + + {(i + 1) * staleGatewayBinWidthMinutes} mins old + + {r} + + ))} + +
+
+ + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/dvpn/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/dvpn/page.tsx new file mode 100644 index 0000000000..e5b5b8fbcf --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/dvpn/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import GatewaysTable from "@/app/dvpn/GatewaysTable"; +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; +import Box from "@mui/material/Box"; + +export default function Page() { + return ( + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/favicon.ico b/nym-node-status-api/nym-node-status-ui/src/app/favicon.ico new file mode 100644 index 0000000000..718d6fea48 Binary files /dev/null and b/nym-node-status-api/nym-node-status-ui/src/app/favicon.ico differ diff --git a/nym-node-status-api/nym-node-status-ui/src/app/layout.tsx b/nym-node-status-api/nym-node-status-ui/src/app/layout.tsx index 83d23fa86d..7eaf4f838a 100644 --- a/nym-node-status-api/nym-node-status-ui/src/app/layout.tsx +++ b/nym-node-status-api/nym-node-status-ui/src/app/layout.tsx @@ -1,10 +1,30 @@ "use client"; +import { QueryContextProvider } from "@/context/queryContext"; +import LayoutWithNav from "@/layouts/LayoutWithNav"; +import AppTheme from "@/theme"; +import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter"; +import CssBaseline from "@mui/material/CssBaseline"; +import InitColorSchemeScript from "@mui/material/InitColorSchemeScript"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; + export default function RootLayout(props: { children: React.ReactNode }) { return ( - {props.children} + + + + {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} + + + + {props.children} + + + + ); diff --git a/nym-node-status-api/nym-node-status-ui/src/app/nodes/NodesTable.tsx b/nym-node-status-api/nym-node-status-ui/src/app/nodes/NodesTable.tsx new file mode 100644 index 0000000000..6ee28b2bcf --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/nodes/NodesTable.tsx @@ -0,0 +1,124 @@ +import { useAllNymNodes } from "@/hooks/useAllNymNodes"; +import type { NymNode } from "@/hooks/useNymNodes"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { IconButton, Tooltip } from "@mui/material"; +import { + type MRT_ColumnDef, + MaterialReactTable, + useMaterialReactTable, +} from "material-react-table"; +import { useMemo } from "react"; + +export default function NodesTable() { + const { data, isError, isRefetching, isLoading, refetch } = + useAllNymNodes().query; + + const columns = useMemo[]>( + //column definitions... + () => [ + { + accessorKey: "node_id", + header: "Node Id", + size: 25, + }, + { + accessorKey: "description.moniker", + header: "Moniker", + }, + { + accessorKey: "identity_key", + header: "Identity Key", + Cell: ({ cell }) => ( + {cell.getValue()?.slice(0, 8)}... + ), + }, + { + accessorKey: "node_type", + header: "Node Type", + }, + { + accessorKey: "bonded", + header: "Bonded", + Cell: ({ cell }) => (cell.getValue() ? "✅" : "⛔️"), + }, + { + accessorKey: "geoip.country", + header: "Country", + }, + { + accessorKey: "geoip.city", + header: "City", + }, + { + accessorKey: "self_description.build_information.build_version", + header: "Version", + }, + { + accessorKey: "self_description.declared_role.entry", + header: "Entry gateway", + Cell: ({ cell }) => (cell.getValue() ? "✅" : "-"), + }, + { + accessorKey: "self_description.declared_role.exit", + header: "Exit gateway", + Cell: ({ cell }) => (cell.getValue() ? "✅" : "-"), + }, + { + accessorKey: "self_description.declared_role.mixnode", + header: "Mixnode", + Cell: ({ cell }) => (cell.getValue() ? "✅" : "-"), + }, + { + accessorKey: "self_description.declared_role.exit_ipr", + header: "Runs IPR", + Cell: ({ cell }) => (cell.getValue() ? "✅" : "-"), + }, + { + accessorKey: "self_description.declared_role.exit_nr", + header: "Runs SOCKS5 NR", + Cell: ({ cell }) => (cell.getValue() ? "✅" : "-"), + }, + { + accessorKey: "self_description.host_information.ip_address", + header: "IP Address", + }, + { + accessorKey: "uptime", + header: "Uptime", + }, + ], + [], + //end + ); + + const table = useMaterialReactTable({ + columns, + data: data || [], + initialState: { + showColumnFilters: true, + density: "compact", + pagination: { pageIndex: 0, pageSize: 100 }, + }, + muiToolbarAlertBannerProps: isError + ? { + color: "error", + children: "Error loading data", + } + : undefined, + renderTopToolbarCustomActions: () => ( + + refetch()}> + + + + ), + rowCount: data?.length ?? 0, + state: { + isLoading, + showAlertBanner: isError, + showProgressBars: isRefetching, + }, + }); + + return ; +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/nodes/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/nodes/page.tsx new file mode 100644 index 0000000000..608f32869c --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/nodes/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import NodesTable from "@/app/nodes/NodesTable"; +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; +import Box from "@mui/material/Box"; + +export default function Page() { + return ( + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/GatewaysTable.tsx b/nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/GatewaysTable.tsx new file mode 100644 index 0000000000..6c1de054d0 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/GatewaysTable.tsx @@ -0,0 +1,115 @@ +import type { DVpnGateway } from "@/client"; +import { useDVpnGateways } from "@/hooks/useGateways"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { IconButton, Tooltip } from "@mui/material"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { + type MRT_ColumnDef, + MaterialReactTable, + useMaterialReactTable, +} from "material-react-table"; +import { useMemo } from "react"; +import ReactCountryFlag from "react-country-flag"; + +dayjs.extend(duration); +dayjs.extend(relativeTime); + +const regionNamesInEnglish = new Intl.DisplayNames(["en"], { type: "region" }); + +export default function GatewaysTable() { + const { data, isError, isRefetching, isLoading, refetch } = + useDVpnGateways().query; + + const columns = useMemo[]>( + //column definitions... + () => [ + { + accessorKey: "name", + header: "Name", + }, + { + accessorKey: "identity_key", + header: "Identity Key", + Cell: ({ cell }) => ( + {cell.getValue()?.slice(0, 8)}... + ), + }, + { + accessorKey: "location.two_letter_iso_country_code", + header: "Country", + Cell: ({ cell }) => { + const value = cell.getValue(); + return ( + <> + {value} + + {regionNamesInEnglish.of(value)} + + + ); + }, + }, + { + accessorKey: "last_probe.last_updated_utc", + header: "Last Probed At", + Cell: ({ cell }) => { + const parsed = dayjs(cell.getValue()); + return ( + +
+ {parsed.format()} +
+
+ ({parsed.fromNow()}) +
+
+ ); + }, + }, + { + accessorKey: "build_information.build_version", + header: "Version", + }, + ], + [], + //end + ); + + const table = useMaterialReactTable({ + columns, + data: data || [], + initialState: { + showColumnFilters: true, + density: "compact", + pagination: { + pageIndex: 0, + pageSize: 100, + }, + }, + muiToolbarAlertBannerProps: isError + ? { + color: "error", + children: "Error loading data", + } + : undefined, + renderTopToolbarCustomActions: () => ( + + refetch()}> + + + + ), + rowCount: data?.length || 0, + state: { + isLoading, + showAlertBanner: isError, + showProgressBars: isRefetching, + }, + }); + + return ; +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/page.tsx new file mode 100644 index 0000000000..6f55cf7dba --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/nym-vpn/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import GatewaysTable from "@/app/dvpn/GatewaysTable"; +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; +import Box from "@mui/material/Box"; + +export default function Page() { + return ( + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/page.tsx index b77b535926..f3157b0289 100644 --- a/nym-node-status-api/nym-node-status-ui/src/app/page.tsx +++ b/nym-node-status-api/nym-node-status-ui/src/app/page.tsx @@ -1,7 +1,61 @@ "use client"; +import GraphCard from "@/components/GraphCard"; +import { GatewayCanQueryMetadataTopup } from "@/components/graphs/GatewayCanQueryMetadataTopup"; +import { GatewayDownloadSpeeds } from "@/components/graphs/GatewayDownloadSpeeds"; +import { GatewayLoads } from "@/components/graphs/GatewayLoads"; +import { GatewayPingPercentage } from "@/components/graphs/GatewayPingPercentage"; +import { GatewayScores } from "@/components/graphs/GatewayScores"; +import { GatewayUptimePercentage } from "@/components/graphs/GatewayUptimePercentage"; +import { GatewayVersions } from "@/components/graphs/GatewayVersions"; +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; +import Grid from "@mui/material/Grid"; + export default function Home() { return ( -
TODO
+ + theme.spacing(2) }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/nym-node-status-api/nym-node-status-ui/src/app/socks5/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/socks5/page.tsx new file mode 100644 index 0000000000..7cd5172647 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/socks5/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; + +export default function Page() { + return ( + +
SOCKS5
+
+ ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/validators/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/validators/page.tsx new file mode 100644 index 0000000000..6ae3f8edda --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/validators/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; + +export default function Page() { + return ( + +
Validators
+
+ ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/app/zk-nym-signers/page.tsx b/nym-node-status-api/nym-node-status-ui/src/app/zk-nym-signers/page.tsx new file mode 100644 index 0000000000..089eca1d97 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/app/zk-nym-signers/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import NestedLayoutWithHeader from "@/layouts/NestedLayoutWithHeader"; + +export default function Page() { + return ( + +
zk-nym signers
+
+ ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/client/@tanstack/react-query.gen.ts b/nym-node-status-api/nym-node-status-ui/src/client/@tanstack/react-query.gen.ts new file mode 100644 index 0000000000..5e672b9bb7 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/@tanstack/react-query.gen.ts @@ -0,0 +1,936 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { + type DefaultError, + type InfiniteData, + infiniteQueryOptions, + queryOptions, +} from "@tanstack/react-query"; +import { client as _heyApiClient } from "../client.gen"; +import { + type Options, + buildInformation, + gateways, + gatewaysSkinny, + getAllSessions, + getEntryGatewayCountries, + getEntryGateways, + getEntryGatewaysByCountry, + getExitGatewayCountries, + getExitGateways, + getExitGatewaysByCountry, + getGateway, + getGatewayCountries, + getGateways, + getGatewaysByCountry, + getMixnodes, + getStats, + health, + mixnodes, + mixnodes2, + nodeDelegations, + nymNodes, + summary, + summaryHistory, +} from "../sdk.gen"; +import type { + BuildInformationData, + GatewaysData, + GatewaysResponse, + GatewaysSkinnyData, + GatewaysSkinnyResponse, + GetAllSessionsData, + GetAllSessionsResponse, + GetEntryGatewayCountriesData, + GetEntryGatewaysByCountryData, + GetEntryGatewaysData, + GetExitGatewayCountriesData, + GetExitGatewaysByCountryData, + GetExitGatewaysData, + GetGatewayCountriesData, + GetGatewayData, + GetGatewaysByCountryData, + GetGatewaysData, + GetMixnodesData, + GetStatsData, + GetStatsResponse, + HealthData, + Mixnodes2Data, + Mixnodes2Response, + MixnodesData, + MixnodesResponse, + NodeDelegationsData, + NymNodesData, + NymNodesResponse, + SummaryData, + SummaryHistoryData, +} from "../types.gen"; + +export type QueryKey = [ + Pick & { + _id: string; + _infinite?: boolean; + }, +]; + +const createQueryKey = ( + id: string, + options?: TOptions, + infinite?: boolean, +): [QueryKey[0]] => { + const params: QueryKey[0] = { + _id: id, + baseUrl: (options?.client ?? _heyApiClient).getConfig().baseUrl, + } as QueryKey[0]; + if (infinite) { + params._infinite = infinite; + } + if (options?.body) { + params.body = options.body; + } + if (options?.headers) { + params.headers = options.headers; + } + if (options?.path) { + params.path = options.path; + } + if (options?.query) { + params.query = options.query; + } + return [params]; +}; + +export const getGatewaysQueryKey = (options?: Options) => + createQueryKey("getGateways", options); + +/** + * Gets available entry and exit gateways from the Nym network directory + */ +export const getGatewaysOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getGateways({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getGatewaysQueryKey(options), + }); +}; + +export const getGatewayCountriesQueryKey = ( + options?: Options, +) => createQueryKey("getGatewayCountries", options); + +/** + * Gets available exit gateway countries as two-letter ISO country codes from the Nym network directory + */ +export const getGatewayCountriesOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getGatewayCountries({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getGatewayCountriesQueryKey(options), + }); +}; + +export const getGatewaysByCountryQueryKey = ( + options: Options, +) => createQueryKey("getGatewaysByCountry", options); + +/** + * Gets available gateways from the Nym network directory by country + */ +export const getGatewaysByCountryOptions = ( + options: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getGatewaysByCountry({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getGatewaysByCountryQueryKey(options), + }); +}; + +export const getEntryGatewaysQueryKey = ( + options?: Options, +) => createQueryKey("getEntryGateways", options); + +/** + * Gets available entry gateways from the Nym network directory + */ +export const getEntryGatewaysOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getEntryGateways({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getEntryGatewaysQueryKey(options), + }); +}; + +export const getEntryGatewayCountriesQueryKey = ( + options?: Options, +) => createQueryKey("getEntryGatewayCountries", options); + +/** + * Gets available entry gateway countries as two-letter ISO country codes from the Nym network directory + */ +export const getEntryGatewayCountriesOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getEntryGatewayCountries({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getEntryGatewayCountriesQueryKey(options), + }); +}; + +export const getEntryGatewaysByCountryQueryKey = ( + options: Options, +) => createQueryKey("getEntryGatewaysByCountry", options); + +/** + * Gets available entry gateways from the Nym network directory by country + */ +export const getEntryGatewaysByCountryOptions = ( + options: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getEntryGatewaysByCountry({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getEntryGatewaysByCountryQueryKey(options), + }); +}; + +export const getExitGatewaysQueryKey = ( + options?: Options, +) => createQueryKey("getExitGateways", options); + +/** + * Gets available exit gateways from the Nym network directory + */ +export const getExitGatewaysOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getExitGateways({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getExitGatewaysQueryKey(options), + }); +}; + +export const getExitGatewayCountriesQueryKey = ( + options?: Options, +) => createQueryKey("getExitGatewayCountries", options); + +/** + * Gets available exit gateway countries as two-letter ISO country codes from the Nym network directory + */ +export const getExitGatewayCountriesOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getExitGatewayCountries({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getExitGatewayCountriesQueryKey(options), + }); +}; + +export const getExitGatewaysByCountryQueryKey = ( + options: Options, +) => createQueryKey("getExitGatewaysByCountry", options); + +/** + * Gets available exit gateways from the Nym network directory by country + */ +export const getExitGatewaysByCountryOptions = ( + options: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getExitGatewaysByCountry({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getExitGatewaysByCountryQueryKey(options), + }); +}; + +export const nymNodesQueryKey = (options?: Options) => + createQueryKey("nymNodes", options); + +export const nymNodesOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await nymNodes({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: nymNodesQueryKey(options), + }); +}; + +const createInfiniteParams = < + K extends Pick[0], "body" | "headers" | "path" | "query">, +>( + queryKey: QueryKey, + page: K, +) => { + const params = { + ...queryKey[0], + }; + if (page.body) { + params.body = { + ...(queryKey[0].body as any), + ...(page.body as any), + }; + } + if (page.headers) { + params.headers = { + ...queryKey[0].headers, + ...page.headers, + }; + } + if (page.path) { + params.path = { + ...(queryKey[0].path as any), + ...(page.path as any), + }; + } + if (page.query) { + params.query = { + ...(queryKey[0].query as any), + ...(page.query as any), + }; + } + return params as unknown as typeof page; +}; + +export const nymNodesInfiniteQueryKey = ( + options?: Options, +): QueryKey> => createQueryKey("nymNodes", options, true); + +export const nymNodesInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + NymNodesResponse, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await nymNodes({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: nymNodesInfiniteQueryKey(options), + }, + ); +}; + +export const nodeDelegationsQueryKey = ( + options: Options, +) => createQueryKey("nodeDelegations", options); + +export const nodeDelegationsOptions = ( + options: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await nodeDelegations({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: nodeDelegationsQueryKey(options), + }); +}; + +export const gatewaysQueryKey = (options?: Options) => + createQueryKey("gateways", options); + +export const gatewaysOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await gateways({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: gatewaysQueryKey(options), + }); +}; + +export const gatewaysInfiniteQueryKey = ( + options?: Options, +): QueryKey> => createQueryKey("gateways", options, true); + +export const gatewaysInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + GatewaysResponse, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await gateways({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: gatewaysInfiniteQueryKey(options), + }, + ); +}; + +export const gatewaysSkinnyQueryKey = (options?: Options) => + createQueryKey("gatewaysSkinny", options); + +export const gatewaysSkinnyOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await gatewaysSkinny({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: gatewaysSkinnyQueryKey(options), + }); +}; + +export const gatewaysSkinnyInfiniteQueryKey = ( + options?: Options, +): QueryKey> => + createQueryKey("gatewaysSkinny", options, true); + +export const gatewaysSkinnyInfiniteOptions = ( + options?: Options, +) => { + return infiniteQueryOptions< + GatewaysSkinnyResponse, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await gatewaysSkinny({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: gatewaysSkinnyInfiniteQueryKey(options), + }, + ); +}; + +export const getGatewayQueryKey = (options: Options) => + createQueryKey("getGateway", options); + +export const getGatewayOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getGateway({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getGatewayQueryKey(options), + }); +}; + +export const getAllSessionsQueryKey = (options?: Options) => + createQueryKey("getAllSessions", options); + +export const getAllSessionsOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getAllSessions({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getAllSessionsQueryKey(options), + }); +}; + +export const getAllSessionsInfiniteQueryKey = ( + options?: Options, +): QueryKey> => + createQueryKey("getAllSessions", options, true); + +export const getAllSessionsInfiniteOptions = ( + options?: Options, +) => { + return infiniteQueryOptions< + GetAllSessionsResponse, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getAllSessions({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getAllSessionsInfiniteQueryKey(options), + }, + ); +}; + +export const mixnodesQueryKey = (options?: Options) => + createQueryKey("mixnodes", options); + +export const mixnodesOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await mixnodes({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: mixnodesQueryKey(options), + }); +}; + +export const mixnodesInfiniteQueryKey = ( + options?: Options, +): QueryKey> => createQueryKey("mixnodes", options, true); + +export const mixnodesInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + MixnodesResponse, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await mixnodes({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: mixnodesInfiniteQueryKey(options), + }, + ); +}; + +export const getStatsQueryKey = (options?: Options) => + createQueryKey("getStats", options); + +export const getStatsOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getStats({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getStatsQueryKey(options), + }); +}; + +export const getStatsInfiniteQueryKey = ( + options?: Options, +): QueryKey> => createQueryKey("getStats", options, true); + +export const getStatsInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + GetStatsResponse, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + offset: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getStats({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getStatsInfiniteQueryKey(options), + }, + ); +}; + +export const getMixnodesQueryKey = (options: Options) => + createQueryKey("getMixnodes", options); + +export const getMixnodesOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getMixnodes({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getMixnodesQueryKey(options), + }); +}; + +export const mixnodes2QueryKey = (options?: Options) => + createQueryKey("mixnodes2", options); + +export const mixnodes2Options = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await mixnodes2({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: mixnodes2QueryKey(options), + }); +}; + +export const mixnodes2InfiniteQueryKey = ( + options?: Options, +): QueryKey> => + createQueryKey("mixnodes2", options, true); + +export const mixnodes2InfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + Mixnodes2Response, + DefaultError, + InfiniteData, + QueryKey>, + | number + | Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await mixnodes2({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: mixnodes2InfiniteQueryKey(options), + }, + ); +}; + +export const buildInformationQueryKey = ( + options?: Options, +) => createQueryKey("buildInformation", options); + +export const buildInformationOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await buildInformation({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: buildInformationQueryKey(options), + }); +}; + +export const healthQueryKey = (options?: Options) => + createQueryKey("health", options); + +export const healthOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await health({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: healthQueryKey(options), + }); +}; + +export const summaryQueryKey = (options?: Options) => + createQueryKey("summary", options); + +export const summaryOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await summary({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: summaryQueryKey(options), + }); +}; + +export const summaryHistoryQueryKey = (options?: Options) => + createQueryKey("summaryHistory", options); + +export const summaryHistoryOptions = ( + options?: Options, +) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await summaryHistory({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: summaryHistoryQueryKey(options), + }); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/client.gen.ts b/nym-node-status-api/nym-node-status-ui/src/client/client.gen.ts new file mode 100644 index 0000000000..d4af1d498e --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/client.gen.ts @@ -0,0 +1,28 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { + type Config, + type ClientOptions as DefaultClientOptions, + createClient, + createConfig, +} from "./client"; +import type { ClientOptions } from "./types.gen"; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = + ( + override?: Config, + ) => Config & T>; + +export const client = createClient( + createConfig({ + baseUrl: "https://mainnet-node-status-api.nymtech.cc", + }), +); diff --git a/nym-node-status-api/nym-node-status-ui/src/client/client/client.ts b/nym-node-status-api/nym-node-status-ui/src/client/client/client.ts new file mode 100644 index 0000000000..748558f584 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/client/client.ts @@ -0,0 +1,195 @@ +import type { Client, Config, RequestOptions } from "./types"; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from "./utils"; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors< + Request, + Response, + unknown, + RequestOptions + >(); + + const request: Client["request"] = async (options) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body && opts.bodySerializer) { + opts.body = opts.bodySerializer(opts.body); + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.body === "") { + opts.headers.delete("Content-Type"); + } + + const url = buildUrl(opts); + const requestInit: ReqInit = { + redirect: "follow", + ...opts, + }; + + let request = new Request(url, requestInit); + + for (const fn of interceptors.request._fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + let response = await _fetch(request); + + for (const fn of interceptors.response._fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + if ( + response.status === 204 || + response.headers.get("Content-Length") === "0" + ) { + return opts.responseStyle === "data" + ? {} + : { + data: {}, + ...result, + }; + } + + const parseAs = + (opts.parseAs === "auto" + ? getParseAs(response.headers.get("Content-Type")) + : opts.parseAs) ?? "json"; + + let data: any; + switch (parseAs) { + case "arrayBuffer": + case "blob": + case "formData": + case "json": + case "text": + data = await response[parseAs](); + break; + case "stream": + return opts.responseStyle === "data" + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === "json") { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === "data" + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + const error = jsonError ?? textError; + let finalError = error; + + for (const fn of interceptors.error._fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string; + } + } + + finalError = finalError || ({} as string); + + if (opts.throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === "data" + ? undefined + : { + error: finalError, + ...result, + }; + }; + + return { + buildUrl, + connect: (options) => request({ ...options, method: "CONNECT" }), + delete: (options) => request({ ...options, method: "DELETE" }), + get: (options) => request({ ...options, method: "GET" }), + getConfig, + head: (options) => request({ ...options, method: "HEAD" }), + interceptors, + options: (options) => request({ ...options, method: "OPTIONS" }), + patch: (options) => request({ ...options, method: "PATCH" }), + post: (options) => request({ ...options, method: "POST" }), + put: (options) => request({ ...options, method: "PUT" }), + request, + setConfig, + trace: (options) => request({ ...options, method: "TRACE" }), + }; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/client/index.ts b/nym-node-status-api/nym-node-status-ui/src/client/client/index.ts new file mode 100644 index 0000000000..7b4add4180 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/client/index.ts @@ -0,0 +1,22 @@ +export type { Auth } from "../core/auth"; +export type { QuerySerializerOptions } from "../core/bodySerializer"; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from "../core/bodySerializer"; +export { buildClientParams } from "../core/params"; +export { createClient } from "./client"; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + OptionsLegacyParser, + RequestOptions, + RequestResult, + ResponseStyle, + TDataShape, +} from "./types"; +export { createConfig, mergeHeaders } from "./utils"; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/client/types.ts b/nym-node-status-api/nym-node-status-ui/src/client/client/types.ts new file mode 100644 index 0000000000..5344ff3d2a --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/client/types.ts @@ -0,0 +1,219 @@ +import type { Auth } from "../core/auth"; +import type { Client as CoreClient, Config as CoreConfig } from "../core/types"; +import type { Middleware } from "./utils"; + +export type ResponseStyle = "data" | "fields"; + +export interface Config + extends Omit, + CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T["baseUrl"]; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: (request: Request) => ReturnType; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: + | "arrayBuffer" + | "auto" + | "blob" + | "formData" + | "json" + | "stream" + | "text"; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T["throwOnError"]; +} + +export interface RequestOptions< + TResponseStyle extends ResponseStyle = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }> { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends "data" + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record + ? TData[keyof TData] + : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends "data" + ? + | (TData extends Record + ? TData[keyof TData] + : TData) + | undefined + : ( + | { + data: TData extends Record + ? TData[keyof TData] + : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record + ? TError[keyof TError] + : TError; + } + ) & { + request: Request; + response: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method">, +) => RequestResult; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method"> & + Pick>, "method">, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: Pick & Options, +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = OmitKeys< + RequestOptions, + "body" | "path" | "query" | "url" +> & + Omit; + +export type OptionsLegacyParser< + TData = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = TData extends { body?: any } + ? TData extends { headers?: any } + ? OmitKeys< + RequestOptions, + "body" | "headers" | "url" + > & + TData + : OmitKeys, "body" | "url"> & + TData & + Pick, "headers"> + : TData extends { headers?: any } + ? OmitKeys< + RequestOptions, + "headers" | "url" + > & + TData & + Pick, "body"> + : OmitKeys, "url"> & TData; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/client/utils.ts b/nym-node-status-api/nym-node-status-ui/src/client/client/utils.ts new file mode 100644 index 0000000000..ff8112bd56 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/client/utils.ts @@ -0,0 +1,417 @@ +import { getAuthToken } from "../core/auth"; +import type { + QuerySerializer, + QuerySerializerOptions, +} from "../core/bodySerializer"; +import { jsonBodySerializer } from "../core/bodySerializer"; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from "../core/pathSerializer"; +import type { Client, ClientOptions, Config, RequestOptions } from "./types"; + +interface PathSerializer { + path: Record; + url: string; +} + +const PATH_PARAM_RE = /\{[^{}]+\}/g; + +type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited"; +type MatrixStyle = "label" | "matrix" | "simple"; +type ArraySeparatorStyle = ArrayStyle | MatrixStyle; + +const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = "simple"; + + if (name.endsWith("*")) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith(".")) { + name = name.substring(1); + style = "label"; + } else if (name.startsWith(";")) { + name = name.substring(1); + style = "matrix"; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === "object") { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === "matrix") { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === "label" ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const createQuerySerializer = ({ + allowReserved, + array, + object, +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === "object") { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved, + explode: true, + name, + style: "form", + value, + ...array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === "object") { + const serializedObject = serializeObjectParam({ + allowReserved, + explode: true, + name, + style: "deepObject", + value: value as Record, + ...object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join("&"); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = ( + contentType: string | null, +): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return "stream"; + } + + const cleanContent = contentType.split(";")[0]?.trim(); + + if (!cleanContent) { + return; + } + + if ( + cleanContent.startsWith("application/json") || + cleanContent.endsWith("+json") + ) { + return "json"; + } + + if (cleanContent === "multipart/form-data") { + return "formData"; + } + + if ( + ["application/", "audio/", "image/", "video/"].some((type) => + cleanContent.startsWith(type), + ) + ) { + return "blob"; + } + + if (cleanContent.startsWith("text/")) { + return "text"; + } + + return; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, "security"> & + Pick & { + headers: Headers; + }) => { + for (const auth of security) { + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? "Authorization"; + + switch (auth.in) { + case "query": + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case "cookie": + options.headers.append("Cookie", `${name}=${token}`); + break; + case "header": + default: + options.headers.set(name, token); + break; + } + + return; + } +}; + +export const buildUrl: Client["buildUrl"] = (options) => { + const url = getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === "function" + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith("/") ? _url : `/${_url}`; + let url = (baseUrl ?? "") + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ""; + if (search.startsWith("?")) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith("/")) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +export const mergeHeaders = ( + ...headers: Array["headers"] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header || typeof header !== "object") { + continue; + } + + const iterator = + header instanceof Headers ? header.entries() : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === "object" ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise; + +type ReqInterceptor = ( + request: Req, + options: Options, +) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + _fns: (Interceptor | null)[]; + + constructor() { + this._fns = []; + } + + clear() { + this._fns = []; + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === "number") { + return this._fns[id] ? id : -1; + } else { + return this._fns.indexOf(id); + } + } + exists(id: number | Interceptor) { + const index = this.getInterceptorIndex(id); + return !!this._fns[index]; + } + + eject(id: number | Interceptor) { + const index = this.getInterceptorIndex(id); + if (this._fns[index]) { + this._fns[index] = null; + } + } + + update(id: number | Interceptor, fn: Interceptor) { + const index = this.getInterceptorIndex(id); + if (this._fns[index]) { + this._fns[index] = fn; + return id; + } else { + return false; + } + } + + use(fn: Interceptor) { + this._fns = [...this._fns, fn]; + return this._fns.length - 1; + } +} + +// `createInterceptors()` response, meant for external use as it does not +// expose internals +export interface Middleware { + error: Pick< + Interceptors>, + "eject" | "use" + >; + request: Pick>, "eject" | "use">; + response: Pick< + Interceptors>, + "eject" | "use" + >; +} + +// do not add `Middleware` as return type so we can use _fns internally +export const createInterceptors = () => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: "form", + }, + object: { + explode: true, + style: "deepObject", + }, +}); + +const defaultHeaders = { + "Content-Type": "application/json", +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: "auto", + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/nym-node-status-api/nym-node-status-ui/src/client/core/auth.ts b/nym-node-status-api/nym-node-status-ui/src/client/core/auth.ts new file mode 100644 index 0000000000..70e9d5dba5 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/core/auth.ts @@ -0,0 +1,40 @@ +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: "header" | "query" | "cookie"; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: "basic" | "bearer"; + type: "apiKey" | "http"; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = + typeof callback === "function" ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === "bearer") { + return `Bearer ${token}`; + } + + if (auth.scheme === "basic") { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/core/bodySerializer.ts b/nym-node-status-api/nym-node-status-ui/src/client/core/bodySerializer.ts new file mode 100644 index 0000000000..35e2a17e1a --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/core/bodySerializer.ts @@ -0,0 +1,88 @@ +import type { + ArrayStyle, + ObjectStyle, + SerializerOptions, +} from "./pathSerializer"; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: any) => any; + +export interface QuerySerializerOptions { + allowReserved?: boolean; + array?: SerializerOptions; + object?: SerializerOptions; +} + +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { + if (typeof value === "string" || value instanceof Blob) { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = ( + data: URLSearchParams, + key: string, + value: unknown, +): void => { + if (typeof value === "string") { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): FormData => { + const data = new FormData(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: T): string => + JSON.stringify(body, (_key, value) => + typeof value === "bigint" ? value.toString() : value, + ), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): string => { + const data = new URLSearchParams(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/core/params.ts b/nym-node-status-api/nym-node-status-ui/src/client/core/params.ts new file mode 100644 index 0000000000..0771542b14 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/core/params.ts @@ -0,0 +1,141 @@ +type Slot = "body" | "headers" | "path" | "query"; + +export type Field = + | { + in: Exclude; + key: string; + map?: string; + } + | { + in: Extract; + key?: string; + map?: string; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: "body", + $headers_: "headers", + $path_: "path", + $query_: "query", +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + { + in: Slot; + map?: string; + } +>; + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ("in" in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record; + path: Record; + query: Record; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === "object" && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = ( + args: ReadonlyArray, + fields: FieldsConfig, +) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ("in" in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + (params[field.in] as Record)[name] = arg; + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + const name = field.map || key; + (params[field.in] as Record)[name] = value; + } else { + const extra = extraPrefixes.find(([prefix]) => + key.startsWith(prefix), + ); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record)[ + key.slice(prefix.length) + ] = value; + } else { + for (const [slot, allowed] of Object.entries( + config.allowExtra ?? {}, + )) { + if (allowed) { + (params[slot as Slot] as Record)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/core/pathSerializer.ts b/nym-node-status-api/nym-node-status-ui/src/client/core/pathSerializer.ts new file mode 100644 index 0000000000..4052ad1279 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/core/pathSerializer.ts @@ -0,0 +1,179 @@ +interface SerializeOptions + extends SerializePrimitiveOptions, + SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited"; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = "label" | "matrix" | "simple"; +export type ObjectStyle = "form" | "deepObject"; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "label": + return "."; + case "matrix": + return ";"; + case "simple": + return ","; + default: + return "&"; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "form": + return ","; + case "pipeDelimited": + return "|"; + case "spaceDelimited": + return "%20"; + default: + return ","; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case "label": + return "."; + case "matrix": + return ";"; + case "simple": + return ","; + default: + return "&"; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case "label": + return `.${joinedValues}`; + case "matrix": + return `;${name}=${joinedValues}`; + case "simple": + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === "label" || style === "simple") { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === "label" || style === "matrix" + ? separator + joinedValues + : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ""; + } + + if (typeof value === "object") { + throw new Error( + "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== "deepObject" && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [ + ...values, + key, + allowReserved ? (v as string) : encodeURIComponent(v as string), + ]; + }); + const joinedValues = values.join(","); + switch (style) { + case "form": + return `${name}=${joinedValues}`; + case "label": + return `.${joinedValues}`; + case "matrix": + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === "deepObject" ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === "label" || style === "matrix" + ? separator + joinedValues + : joinedValues; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/core/types.ts b/nym-node-status-api/nym-node-status-ui/src/client/core/types.ts new file mode 100644 index 0000000000..940f22a77f --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/core/types.ts @@ -0,0 +1,104 @@ +import type { Auth, AuthToken } from "./auth"; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from "./bodySerializer"; + +export interface Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, +> { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + connect: MethodFn; + delete: MethodFn; + get: MethodFn; + getConfig: () => Config; + head: MethodFn; + options: MethodFn; + patch: MethodFn; + post: MethodFn; + put: MethodFn; + request: RequestFn; + setConfig: (config: Config) => Config; + trace: MethodFn; +} + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit["headers"] + | Record< + string, + | string + | number + | boolean + | (string | number | boolean)[] + | null + | undefined + | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: + | "CONNECT" + | "DELETE" + | "GET" + | "HEAD" + | "OPTIONS" + | "PATCH" + | "POST" + | "PUT" + | "TRACE"; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} diff --git a/nym-node-status-api/nym-node-status-ui/src/client/index.ts b/nym-node-status-api/nym-node-status-ui/src/client/index.ts new file mode 100644 index 0000000000..da87079367 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from "./types.gen"; +export * from "./sdk.gen"; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/sdk.gen.ts b/nym-node-status-api/nym-node-status-ui/src/client/sdk.gen.ts new file mode 100644 index 0000000000..b571278744 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/sdk.gen.ts @@ -0,0 +1,395 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, Options as ClientOptions, TDataShape } from "./client"; +import { client as _heyApiClient } from "./client.gen"; +import type { + BuildInformationData, + BuildInformationResponses, + GatewaysData, + GatewaysResponses, + GatewaysSkinnyData, + GatewaysSkinnyResponses, + GetAllSessionsData, + GetAllSessionsResponses, + GetEntryGatewayCountriesData, + GetEntryGatewayCountriesResponses, + GetEntryGatewaysByCountryData, + GetEntryGatewaysByCountryResponses, + GetEntryGatewaysData, + GetEntryGatewaysResponses, + GetExitGatewayCountriesData, + GetExitGatewayCountriesResponses, + GetExitGatewaysByCountryData, + GetExitGatewaysByCountryResponses, + GetExitGatewaysData, + GetExitGatewaysResponses, + GetGatewayCountriesData, + GetGatewayCountriesResponses, + GetGatewayData, + GetGatewayResponses, + GetGatewaysByCountryData, + GetGatewaysByCountryResponses, + GetGatewaysData, + GetGatewaysResponses, + GetMixnodesData, + GetMixnodesResponses, + GetStatsData, + GetStatsResponses, + HealthData, + HealthResponses, + Mixnodes2Data, + Mixnodes2Responses, + MixnodesData, + MixnodesResponses, + NodeDelegationsData, + NodeDelegationsResponses, + NymNodesData, + NymNodesResponses, + SummaryData, + SummaryHistoryData, + SummaryHistoryResponses, + SummaryResponses, +} from "./types.gen"; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, +> = ClientOptions & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; +}; + +/** + * Gets available entry and exit gateways from the Nym network directory + */ +export const getGateways = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetGatewaysResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways", + ...options, + }); +}; + +/** + * Gets available exit gateway countries as two-letter ISO country codes from the Nym network directory + */ +export const getGatewayCountries = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetGatewayCountriesResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/countries", + ...options, + }); +}; + +/** + * Gets available gateways from the Nym network directory by country + */ +export const getGatewaysByCountry = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + GetGatewaysByCountryResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/country/{two_letter_country_code}", + ...options, + }); +}; + +/** + * Gets available entry gateways from the Nym network directory + */ +export const getEntryGateways = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetEntryGatewaysResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/entry", + ...options, + }); +}; + +/** + * Gets available entry gateway countries as two-letter ISO country codes from the Nym network directory + */ +export const getEntryGatewayCountries = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetEntryGatewayCountriesResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/entry/countries", + ...options, + }); +}; + +/** + * Gets available entry gateways from the Nym network directory by country + */ +export const getEntryGatewaysByCountry = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + GetEntryGatewaysByCountryResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/entry/country/{two_letter_country_code}", + ...options, + }); +}; + +/** + * Gets available exit gateways from the Nym network directory + */ +export const getExitGateways = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetExitGatewaysResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/exit", + ...options, + }); +}; + +/** + * Gets available exit gateway countries as two-letter ISO country codes from the Nym network directory + */ +export const getExitGatewayCountries = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetExitGatewayCountriesResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/exit/countries", + ...options, + }); +}; + +/** + * Gets available exit gateways from the Nym network directory by country + */ +export const getExitGatewaysByCountry = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + GetExitGatewaysByCountryResponses, + unknown, + ThrowOnError + >({ + url: "/dvpn/v1/directory/gateways/exit/country/{two_letter_country_code}", + ...options, + }); +}; + +export const nymNodes = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + NymNodesResponses, + unknown, + ThrowOnError + >({ + url: "/explorer/v3/nym-nodes", + ...options, + }); +}; + +export const nodeDelegations = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + NodeDelegationsResponses, + unknown, + ThrowOnError + >({ + url: "/explorer/v3/nym-nodes/{node_id}/delegations", + ...options, + }); +}; + +export const gateways = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GatewaysResponses, + unknown, + ThrowOnError + >({ + url: "/v2/gateways", + ...options, + }); +}; + +export const gatewaysSkinny = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GatewaysSkinnyResponses, + unknown, + ThrowOnError + >({ + url: "/v2/gateways/skinny", + ...options, + }); +}; + +export const getGateway = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + GetGatewayResponses, + unknown, + ThrowOnError + >({ + url: "/v2/gateways/{identity_key}", + ...options, + }); +}; + +export const getAllSessions = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetAllSessionsResponses, + unknown, + ThrowOnError + >({ + url: "/v2/metrics/sessions", + ...options, + }); +}; + +export const mixnodes = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + MixnodesResponses, + unknown, + ThrowOnError + >({ + url: "/v2/mixnodes", + ...options, + }); +}; + +export const getStats = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + GetStatsResponses, + unknown, + ThrowOnError + >({ + url: "/v2/mixnodes/stats", + ...options, + }); +}; + +export const getMixnodes = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + GetMixnodesResponses, + unknown, + ThrowOnError + >({ + url: "/v2/mixnodes/{mix_id}", + ...options, + }); +}; + +export const mixnodes2 = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + Mixnodes2Responses, + unknown, + ThrowOnError + >({ + url: "/v2/services", + ...options, + }); +}; + +export const buildInformation = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + BuildInformationResponses, + unknown, + ThrowOnError + >({ + url: "/v2/status/build_information", + ...options, + }); +}; + +export const health = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + HealthResponses, + unknown, + ThrowOnError + >({ + url: "/v2/status/health", + ...options, + }); +}; + +export const summary = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + SummaryResponses, + unknown, + ThrowOnError + >({ + url: "/v2/summary", + ...options, + }); +}; + +export const summaryHistory = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + SummaryHistoryResponses, + unknown, + ThrowOnError + >({ + url: "/v2/summary/history", + ...options, + }); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/client/types.gen.ts b/nym-node-status-api/nym-node-status-ui/src/client/types.gen.ts new file mode 100644 index 0000000000..bb6b7f5839 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/client/types.gen.ts @@ -0,0 +1,940 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AnnouncePorts = { + mix_port?: number | null; + verloc_port?: number | null; +}; + +export type AuthenticatorDetails = { + /** + * address of the embedded authenticator + */ + address: string; +}; + +/** + * Auxiliary details of the associated Nym Node. + */ +export type AuxiliaryDetails = { + /** + * Specifies whether this node operator has agreed to the terms and conditions + * as defined at + */ + accepted_operator_terms_and_conditions?: boolean; + announce_ports?: AnnouncePorts; + /** + * Optional ISO 3166 alpha-2 two-letter country code of the node's **physical** location + */ + location?: string | null; +}; + +export type BasicEntryInformation = { + hostname?: string | null; + ws_port: number; + wss_port?: number | null; +}; + +export type BinaryBuildInformationOwned = { + /** + * Provides the name of the binary, i.e. the content of `CARGO_PKG_NAME` environmental variable. + */ + binary_name: string; + /** + * Provides the build timestamp, for example `2021-02-23T20:14:46.558472672+00:00`. + */ + build_timestamp: string; + /** + * Provides the build version, for example `0.1.0-9-g46f83e1`. + */ + build_version: string; + /** + * Provides the cargo debug mode that was used for the build. + */ + cargo_profile: string; + /** + * Provides the cargo target triple that was used for the build. + */ + cargo_triple?: string; + /** + * Provides the name of the git branch that was used for the build, for example `master`. + */ + commit_branch: string; + /** + * Provides the hash of the commit that was used for the build, for example `46f83e112520533338245862d366f6a02cef07d4`. + */ + commit_sha: string; + /** + * Provides the timestamp of the commit that was used for the build, for example `2021-02-23T08:08:02-05:00`. + */ + commit_timestamp: string; + /** + * Provides the rustc channel that was used for the build, for example `nightly`. + */ + rustc_channel: string; + /** + * Provides the rustc version that was used for the build, for example `1.52.0-nightly`. + */ + rustc_version: string; +}; + +export type BuildInformation = { + build_version: string; + commit_branch: string; + commit_sha: string; +}; + +/** + * Coin + */ +export type CoinSchema = { + amount: string; + denom: string; +}; + +export type DVpnGateway = { + authenticator?: null | AuthenticatorDetails; + build_information: BinaryBuildInformationOwned; + entry?: null | BasicEntryInformation; + identity_key: string; + ip_addresses: Array; + ip_packet_router?: null | IpPacketRouterDetails; + last_probe?: null | DirectoryGwProbe; + location: Location; + mix_port: number; + name: string; + performance: string; + role: NodeRole; +}; + +export type DailyStats = { + date_utc: string; + total_packets_dropped: number; + total_packets_received: number; + total_packets_sent: number; + total_stake: number; +}; + +export type DeclaredRoles = { + entry: boolean; + exit_ipr: boolean; + exit_nr: boolean; + mixnode: boolean; +}; + +export type DescribedNodeType = + | "legacy_mixnode" + | "legacy_gateway" + | "nym_node"; + +export type DirectoryGwProbe = { + last_updated_utc: string; + outcome: ProbeOutcome; +}; + +export type Entry = EntryTestResult | null; + +export type EntryTestResult = { + can_connect: boolean; + can_route: boolean; +}; + +export type Exit = { + can_connect: boolean; + can_route_ip_external_v4: boolean; + can_route_ip_external_v6: boolean; + can_route_ip_v4: boolean; + can_route_ip_v6: boolean; +}; + +export type ExtendedNymNode = { + accepted_tnc: boolean; + bonded: boolean; + bonding_address?: string | null; + description: NodeDescription; + geoip?: null | NodeGeoData; + identity_key: string; + ip_address: string; + node_id: U32; + node_type: DescribedNodeType; + original_pledge: number; + rewarding_details?: null | NodeRewarding; + self_description: NymNodeData; + total_stake: string; + uptime: number; +}; + +export type Gateway = { + bonded: boolean; + config_score: number; + description: NodeDescription; + explorer_pretty_bond?: unknown; + gateway_identity_key: string; + last_probe_log?: string | null; + last_probe_result?: unknown; + last_testrun_utc?: string | null; + last_updated_utc: string; + performance: number; + routing_score: number; + self_described?: unknown; +}; + +export type GatewaySkinny = { + config_score: number; + explorer_pretty_bond?: unknown; + gateway_identity_key: string; + last_probe_result?: unknown; + last_testrun_utc?: string | null; + last_updated_utc: string; + performance: number; + routing_score: number; + self_described?: unknown; +}; + +export type GatewaySummary = { + bonded: GatewaySummaryBonded; + historical: GatewaySummaryHistorical; +}; + +export type GatewaySummaryBonded = { + count: number; + entry: number; + exit: number; + last_updated_utc: string; +}; + +export type GatewaySummaryHistorical = { + count: number; + last_updated_utc: string; +}; + +export type HealthInfo = { + uptime: number; +}; + +export type HostInformation = { + hostname?: string | null; + ip_address: Array; + keys: HostKeys; +}; + +export type HostKeys = { + ed25519: string; + x25519: string; + x25519_noise?: string; +}; + +export type IpPacketRouterDetails = { + /** + * address of the embedded ip packet router + */ + address: string; +}; + +/** + * based on + * https://github.com/nymtech/nym-vpn-client/blob/nym-vpn-core-v1.10.0/nym-vpn-core/crates/nym-gateway-probe/src/types.rs + * TODO: long term types should be moved into this repo because nym-vpn-client + * could pull it as a dependency and we'd have a single source of truth + */ +export type LastProbeResult = { + node: string; + outcome: ProbeOutcome; + used_entry: string; +}; + +export type Location = { + latitude: number; + longitude: number; + two_letter_iso_country_code: string; +}; + +export type MixingNodesSummary = { + count: number; + last_updated_utc: string; + legacy: number; + self_described: number; +}; + +export type Mixnode = { + bonded: boolean; + description: NodeDescription; + full_details?: unknown; + is_dp_delegatee: boolean; + last_updated_utc: string; + mix_id: number; + self_described?: unknown; + total_stake: number; +}; + +export type MixnodeSummary = { + bonded: MixingNodesSummary; + historical: MixnodeSummaryHistorical; +}; + +export type MixnodeSummaryHistorical = { + count: number; + last_updated_utc: string; +}; + +export type NetworkRequesterDetails = { + /** + * address of the embedded network requester + */ + address: string; + /** + * flag indicating whether this network requester uses the exit policy rather than the deprecated allow list + */ + uses_exit_policy: boolean; +}; + +export type NetworkSummary = { + gateways: GatewaySummary; + mixnodes: MixnodeSummary; + total_nodes: number; +}; + +/** + * The cost parameters, or the cost function, defined for the particular mixnode that influences + * how the rewards should be split between the node operator and its delegators. + */ +export type NodeCostParams = { + /** + * Operating cost of the associated node per the entire interval. + */ + interval_operating_cost: CoinSchema; + /** + * The profit margin of the associated node, i.e. the desired percent of the reward to be distributed to the operator. + */ + profit_margin_percent: string; +}; + +export type NodeDelegation = { + amount: CoinSchema; + block_height: number; + cumulative_reward_ratio: string; + owner: string; + proxy?: string | null; +}; + +export type NodeDescription = { + /** + * details define other optional details. + */ + details: string; + /** + * moniker defines a human-readable name for the node. + */ + moniker: string; + /** + * security contact defines an optional email for security contact. + */ + security_contact: string; + /** + * website defines an optional website link. + */ + website: string; +}; + +export type NodeGeoData = { + city: string; + country: string; + ip_address: string; + latitude: string; + longitude: string; + org: string; + postal: string; + region: string; + timezone: string; +}; + +export type NodeRewarding = { + /** + * Information provided by the operator that influence the cost function. + */ + cost_params: NodeCostParams; + /** + * Total delegation and compounded reward earned by all node delegators. + */ + delegates: string; + /** + * Marks the epoch when this node was last rewarded so that we wouldn't accidentally attempt + * to reward it multiple times in the same epoch. + */ + last_rewarded_epoch: U32; + /** + * Total pledge and compounded reward earned by the node operator. + */ + operator: string; + /** + * Cumulative reward earned by the "unit delegation" since the block 0. + */ + total_unit_reward: string; + unique_delegations: number; + /** + * Value of the theoretical "unit delegation" that has delegated to this node at block 0. + */ + unit_delegation: string; +}; + +export type NodeRole = + | { + Mixnode: { + layer: number; + }; + } + | "EntryGateway" + | "ExitGateway" + | "Standby" + | "Inactive"; + +export type NymNodeData = { + authenticator?: null | AuthenticatorDetails; + auxiliary_details?: AuxiliaryDetails; + build_information: BinaryBuildInformationOwned; + declared_role?: DeclaredRoles; + host_information: HostInformation; + ip_packet_router?: null | IpPacketRouterDetails; + last_polled?: OffsetDateTimeJsonSchemaWrapper; + mixnet_websockets: WebSockets; + network_requester?: null | NetworkRequesterDetails; + wireguard?: null | WireguardDetails; +}; + +export type OffsetDateTimeJsonSchemaWrapper = string; + +export type PagedResultExtendedNymNode = { + items: Array<{ + accepted_tnc: boolean; + bonded: boolean; + bonding_address?: string | null; + description: NodeDescription; + geoip?: null | NodeGeoData; + identity_key: string; + ip_address: string; + node_id: U32; + node_type: DescribedNodeType; + original_pledge: number; + rewarding_details?: null | NodeRewarding; + self_description: NymNodeData; + total_stake: string; + uptime: number; + }>; + page: number; + size: number; + total: number; +}; + +export type PagedResultGateway = { + items: Array<{ + bonded: boolean; + config_score: number; + description: NodeDescription; + explorer_pretty_bond?: unknown; + gateway_identity_key: string; + last_probe_log?: string | null; + last_probe_result?: unknown; + last_testrun_utc?: string | null; + last_updated_utc: string; + performance: number; + routing_score: number; + self_described?: unknown; + }>; + page: number; + size: number; + total: number; +}; + +export type PagedResultGatewaySkinny = { + items: Array<{ + config_score: number; + explorer_pretty_bond?: unknown; + gateway_identity_key: string; + last_probe_result?: unknown; + last_testrun_utc?: string | null; + last_updated_utc: string; + performance: number; + routing_score: number; + self_described?: unknown; + }>; + page: number; + size: number; + total: number; +}; + +export type PagedResultMixnode = { + items: Array<{ + bonded: boolean; + description: NodeDescription; + full_details?: unknown; + is_dp_delegatee: boolean; + last_updated_utc: string; + mix_id: number; + self_described?: unknown; + total_stake: number; + }>; + page: number; + size: number; + total: number; +}; + +export type PagedResultService = { + items: Array<{ + gateway_identity_key: string; + hostname?: string | null; + ip_address?: string | null; + last_successful_ping_utc?: string | null; + last_updated_utc: string; + mixnet_websockets?: unknown; + routing_score: number; + service_provider_client_id?: string | null; + }>; + page: number; + size: number; + total: number; +}; + +export type PagedResultSessionStats = { + items: Array<{ + day: string; + gateway_identity_key: string; + mixnet_sessions?: unknown; + node_id: number; + session_started: number; + unique_active_clients: number; + unknown_sessions?: unknown; + users_hashes?: unknown; + vpn_sessions?: unknown; + }>; + page: number; + size: number; + total: number; +}; + +export type ProbeOutcome = { + as_entry: Entry; + as_exit?: null | Exit; + wg?: null | ProbeOutcomeV1; +}; + +export type ProbeOutcomeV1 = { + can_handshake_v4: boolean; + can_handshake_v6: boolean; + can_register: boolean; + can_resolve_dns_v4: boolean; + can_resolve_dns_v6: boolean; + download_duration_sec_v4: number; + download_duration_sec_v6: number; + download_error_v4: string; + download_error_v6: string; + downloaded_file_v4: string; + downloaded_file_v6: string; + ping_hosts_performance_v4: number; + ping_hosts_performance_v6: number; + ping_ips_performance_v4: number; + ping_ips_performance_v6: number; +}; + +export type Service = { + gateway_identity_key: string; + hostname?: string | null; + ip_address?: string | null; + last_successful_ping_utc?: string | null; + last_updated_utc: string; + mixnet_websockets?: unknown; + routing_score: number; + service_provider_client_id?: string | null; +}; + +export type SessionStats = { + day: string; + gateway_identity_key: string; + mixnet_sessions?: unknown; + node_id: number; + session_started: number; + unique_active_clients: number; + unknown_sessions?: unknown; + users_hashes?: unknown; + vpn_sessions?: unknown; +}; + +export type SummaryHistory = { + date: string; + timestamp_utc: string; + value_json: unknown; +}; + +export type TestRun = { + id: number; + identity_key: string; + log: string; + status: string; +}; + +export type WebSockets = { + ws_port: number; + wss_port?: number | null; +}; + +export type WireguardDetails = { + port: number; + public_key: string; +}; + +export type U32 = number; + +export type GetGatewaysData = { + body?: never; + path?: never; + query?: { + min_node_version?: string; + }; + url: "/dvpn/v1/directory/gateways"; +}; + +export type GetGatewaysResponses = { + 200: Array; +}; + +export type GetGatewaysResponse = + GetGatewaysResponses[keyof GetGatewaysResponses]; + +export type GetGatewayCountriesData = { + body?: never; + path?: never; + query?: never; + url: "/dvpn/v1/directory/gateways/countries"; +}; + +export type GetGatewayCountriesResponses = { + 200: Array; +}; + +export type GetGatewayCountriesResponse = + GetGatewayCountriesResponses[keyof GetGatewayCountriesResponses]; + +export type GetGatewaysByCountryData = { + body?: never; + path: { + two_letter_country_code: string; + }; + query?: never; + url: "/dvpn/v1/directory/gateways/country/{two_letter_country_code}"; +}; + +export type GetGatewaysByCountryResponses = { + 200: Array; +}; + +export type GetGatewaysByCountryResponse = + GetGatewaysByCountryResponses[keyof GetGatewaysByCountryResponses]; + +export type GetEntryGatewaysData = { + body?: never; + path?: never; + query?: never; + url: "/dvpn/v1/directory/gateways/entry"; +}; + +export type GetEntryGatewaysResponses = { + 200: Array; +}; + +export type GetEntryGatewaysResponse = + GetEntryGatewaysResponses[keyof GetEntryGatewaysResponses]; + +export type GetEntryGatewayCountriesData = { + body?: never; + path?: never; + query?: never; + url: "/dvpn/v1/directory/gateways/entry/countries"; +}; + +export type GetEntryGatewayCountriesResponses = { + 200: Array; +}; + +export type GetEntryGatewayCountriesResponse = + GetEntryGatewayCountriesResponses[keyof GetEntryGatewayCountriesResponses]; + +export type GetEntryGatewaysByCountryData = { + body?: never; + path: { + two_letter_country_code: string; + }; + query?: never; + url: "/dvpn/v1/directory/gateways/entry/country/{two_letter_country_code}"; +}; + +export type GetEntryGatewaysByCountryResponses = { + 200: Array; +}; + +export type GetEntryGatewaysByCountryResponse = + GetEntryGatewaysByCountryResponses[keyof GetEntryGatewaysByCountryResponses]; + +export type GetExitGatewaysData = { + body?: never; + path?: never; + query?: never; + url: "/dvpn/v1/directory/gateways/exit"; +}; + +export type GetExitGatewaysResponses = { + 200: Array; +}; + +export type GetExitGatewaysResponse = + GetExitGatewaysResponses[keyof GetExitGatewaysResponses]; + +export type GetExitGatewayCountriesData = { + body?: never; + path?: never; + query?: never; + url: "/dvpn/v1/directory/gateways/exit/countries"; +}; + +export type GetExitGatewayCountriesResponses = { + 200: Array; +}; + +export type GetExitGatewayCountriesResponse = + GetExitGatewayCountriesResponses[keyof GetExitGatewayCountriesResponses]; + +export type GetExitGatewaysByCountryData = { + body?: never; + path: { + two_letter_country_code: string; + }; + query?: never; + url: "/dvpn/v1/directory/gateways/exit/country/{two_letter_country_code}"; +}; + +export type GetExitGatewaysByCountryResponses = { + 200: Array; +}; + +export type GetExitGatewaysByCountryResponse = + GetExitGatewaysByCountryResponses[keyof GetExitGatewaysByCountryResponses]; + +export type NymNodesData = { + body?: never; + path?: never; + query?: { + size?: number; + page?: number; + }; + url: "/explorer/v3/nym-nodes"; +}; + +export type NymNodesResponses = { + 200: PagedResultExtendedNymNode; +}; + +export type NymNodesResponse = NymNodesResponses[keyof NymNodesResponses]; + +export type NodeDelegationsData = { + body?: never; + path: { + node_id: U32; + }; + query?: never; + url: "/explorer/v3/nym-nodes/{node_id}/delegations"; +}; + +export type NodeDelegationsResponses = { + 200: NodeDelegation; +}; + +export type NodeDelegationsResponse = + NodeDelegationsResponses[keyof NodeDelegationsResponses]; + +export type GatewaysData = { + body?: never; + path?: never; + query?: { + size?: number; + page?: number; + }; + url: "/v2/gateways"; +}; + +export type GatewaysResponses = { + 200: PagedResultGateway; +}; + +export type GatewaysResponse = GatewaysResponses[keyof GatewaysResponses]; + +export type GatewaysSkinnyData = { + body?: never; + path?: never; + query?: { + size?: number; + page?: number; + }; + url: "/v2/gateways/skinny"; +}; + +export type GatewaysSkinnyResponses = { + 200: PagedResultGatewaySkinny; +}; + +export type GatewaysSkinnyResponse = + GatewaysSkinnyResponses[keyof GatewaysSkinnyResponses]; + +export type GetGatewayData = { + body?: never; + path: { + identity_key: string; + }; + query?: never; + url: "/v2/gateways/{identity_key}"; +}; + +export type GetGatewayResponses = { + 200: Gateway; +}; + +export type GetGatewayResponse = GetGatewayResponses[keyof GetGatewayResponses]; + +export type GetAllSessionsData = { + body?: never; + path?: never; + query?: { + size?: number; + page?: number; + node_id?: string; + day?: string; + }; + url: "/v2/metrics/sessions"; +}; + +export type GetAllSessionsResponses = { + 200: PagedResultSessionStats; +}; + +export type GetAllSessionsResponse = + GetAllSessionsResponses[keyof GetAllSessionsResponses]; + +export type MixnodesData = { + body?: never; + path?: never; + query?: { + size?: number; + page?: number; + }; + url: "/v2/mixnodes"; +}; + +export type MixnodesResponses = { + 200: PagedResultMixnode; +}; + +export type MixnodesResponse = MixnodesResponses[keyof MixnodesResponses]; + +export type GetStatsData = { + body?: never; + path?: never; + query?: { + offset?: number; + }; + url: "/v2/mixnodes/stats"; +}; + +export type GetStatsResponses = { + 200: Array; +}; + +export type GetStatsResponse = GetStatsResponses[keyof GetStatsResponses]; + +export type GetMixnodesData = { + body?: never; + path: { + mix_id: string; + }; + query?: never; + url: "/v2/mixnodes/{mix_id}"; +}; + +export type GetMixnodesResponses = { + 200: Mixnode; +}; + +export type GetMixnodesResponse = + GetMixnodesResponses[keyof GetMixnodesResponses]; + +export type Mixnodes2Data = { + body?: never; + path?: never; + query?: { + size?: number; + page?: number; + wss?: boolean; + hostname?: boolean; + entry?: boolean; + }; + url: "/v2/services"; +}; + +export type Mixnodes2Responses = { + 200: PagedResultService; +}; + +export type Mixnodes2Response = Mixnodes2Responses[keyof Mixnodes2Responses]; + +export type BuildInformationData = { + body?: never; + path?: never; + query?: never; + url: "/v2/status/build_information"; +}; + +export type BuildInformationResponses = { + 200: BinaryBuildInformationOwned; +}; + +export type BuildInformationResponse = + BuildInformationResponses[keyof BuildInformationResponses]; + +export type HealthData = { + body?: never; + path?: never; + query?: never; + url: "/v2/status/health"; +}; + +export type HealthResponses = { + 200: HealthInfo; +}; + +export type HealthResponse = HealthResponses[keyof HealthResponses]; + +export type SummaryData = { + body?: never; + path?: never; + query?: never; + url: "/v2/summary"; +}; + +export type SummaryResponses = { + 200: NetworkSummary; +}; + +export type SummaryResponse = SummaryResponses[keyof SummaryResponses]; + +export type SummaryHistoryData = { + body?: never; + path?: never; + query?: never; + url: "/v2/summary/history"; +}; + +export type SummaryHistoryResponses = { + 200: Array; +}; + +export type SummaryHistoryResponse = + SummaryHistoryResponses[keyof SummaryHistoryResponses]; + +export type ClientOptions = { + baseUrl: "https://mainnet-node-status-api.nymtech.cc" | (string & {}); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/CardAlert.tsx b/nym-node-status-api/nym-node-status-ui/src/components/CardAlert.tsx new file mode 100644 index 0000000000..9a7a13ac0b --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/CardAlert.tsx @@ -0,0 +1,25 @@ +"use client"; +import AutoAwesomeRoundedIcon from "@mui/icons-material/AutoAwesomeRounded"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; + +export default function CardAlert() { + return ( + + + + + Plan about to expire + + + Enjoy 10% off when renewing your plan today. + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/Copyright.tsx b/nym-node-status-api/nym-node-status-ui/src/components/Copyright.tsx new file mode 100644 index 0000000000..e0167a578a --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/Copyright.tsx @@ -0,0 +1,24 @@ +import Link from "@mui/material/Link"; +import Typography from "@mui/material/Typography"; + +export default function Copyright(props: any) { + return ( + + {"Copyright © "} + + Nym Technologies SA + {" "} + {new Date().getFullYear()} + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/GraphCard.tsx b/nym-node-status-api/nym-node-status-ui/src/components/GraphCard.tsx new file mode 100644 index 0000000000..ee5ea54091 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/GraphCard.tsx @@ -0,0 +1,23 @@ +"use client"; + +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; + +export type GraphProps = { + title: string; + children?: React.ReactNode; +}; + +export default function GraphCard({ title, children }: GraphProps) { + return ( + + + + {title} + + {children} + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/HighlightedCard.tsx b/nym-node-status-api/nym-node-status-ui/src/components/HighlightedCard.tsx new file mode 100644 index 0000000000..f551b85e3d --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/HighlightedCard.tsx @@ -0,0 +1,42 @@ +"use client"; +import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded"; +import InsightsRoundedIcon from "@mui/icons-material/InsightsRounded"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; + +export default function HighlightedCard() { + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down("sm")); + + return ( + + + + + Explore your data + + + Uncover performance and visitor insights with our data wizardry. + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/ScoreIcon.tsx b/nym-node-status-api/nym-node-status-ui/src/components/ScoreIcon.tsx new file mode 100644 index 0000000000..d1c47c549f --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/ScoreIcon.tsx @@ -0,0 +1,36 @@ +import SignalCellularAltIcon from "@mui/icons-material/SignalCellularAlt"; +import SignalCellularAlt1Bar from "@mui/icons-material/SignalCellularAlt1Bar"; +import SignalCellularAlt2Bar from "@mui/icons-material/SignalCellularAlt2Bar"; +import SignalCellularConnectedNoInternet0BarIcon from "@mui/icons-material/SignalCellularConnectedNoInternet0Bar"; + +export const ScoreIcon = ({ score }: { score?: string }) => { + if (!score) { + return ; + } + if (score.toLowerCase() === "offline") { + return ; + } + if (score.toLowerCase() === "high") { + return ; + } + if (score.toLowerCase() === "medium") { + return ; + } + return ; +}; + +export const ReverseScoreIcon = ({ score }: { score?: string }) => { + if (!score) { + return ; + } + if (score.toLowerCase() === "offline") { + return ; + } + if (score.toLowerCase() === "low") { + return ; + } + if (score.toLowerCase() === "medium") { + return ; + } + return ; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/StatCard.tsx b/nym-node-status-api/nym-node-status-ui/src/components/StatCard.tsx new file mode 100644 index 0000000000..0ecf2514a0 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/StatCard.tsx @@ -0,0 +1,130 @@ +"use client"; + +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { areaElementClasses } from "@mui/x-charts/LineChart"; +import { SparkLineChart } from "@mui/x-charts/SparkLineChart"; + +export type StatCardProps = { + title: string; + value: string; + interval: string; + trend: "up" | "down" | "neutral"; + data: number[]; +}; + +function getDaysInMonth(month: number, year: number) { + const date = new Date(year, month, 0); + const monthName = date.toLocaleDateString("en-US", { + month: "short", + }); + const daysInMonth = date.getDate(); + const days = []; + let i = 1; + while (days.length < daysInMonth) { + days.push(`${monthName} ${i}`); + i += 1; + } + return days; +} + +function AreaGradient({ color, id }: { color: string; id: string }) { + return ( + + + + + + + ); +} + +export default function StatCard({ + title, + value, + interval, + trend, + data, +}: StatCardProps) { + const theme = useTheme(); + const daysInWeek = getDaysInMonth(4, 2024); + + const trendColors = { + up: + theme.palette.mode === "light" + ? theme.palette.success.main + : theme.palette.success.dark, + down: + theme.palette.mode === "light" + ? theme.palette.error.main + : theme.palette.error.dark, + neutral: + theme.palette.mode === "light" + ? theme.palette.grey[400] + : theme.palette.grey[700], + }; + + const labelColors = { + up: "success" as const, + down: "error" as const, + neutral: "default" as const, + }; + + const color = labelColors[trend]; + const chartColor = trendColors[trend]; + const trendValues = { up: "+25%", down: "-25%", neutral: "+5%" }; + + return ( + + + + {title} + + + + + + {value} + + + + + {interval} + + + + + + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayCanQueryMetadataTopup.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayCanQueryMetadataTopup.tsx new file mode 100644 index 0000000000..30f066e937 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayCanQueryMetadataTopup.tsx @@ -0,0 +1,58 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import { rollup } from "d3-array"; +import React from "react"; + +export const GatewayCanQueryMetadataTopup = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const results = data.map((g) => { + const r = (g.last_probe?.outcome.wg as any)?.can_query_metadata_v4; + if (r === undefined) { + return "-"; + } + if (r === true) { + return "yes"; + } + return "no"; + }); + // count occurrences of each result + const resultCounts = rollup( + results, + (v) => v.length, + (v) => v, // group by result string + ); + + const chartData = Array.from(resultCounts, ([result, count]) => ({ + result, + count, + })); + + const labels = chartData.map((d) => d.result); + const values = chartData.map((d) => d.count); + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayDownloadSpeeds.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayDownloadSpeeds.tsx new file mode 100644 index 0000000000..17ad7c128f --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayDownloadSpeeds.tsx @@ -0,0 +1,43 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import { bin } from "d3-array"; +import React from "react"; + +export const GatewayDownloadSpeeds = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const binner = bin().thresholds(10); // Number of bins + const bins = binner( + data + .map((g) => g.extra.downloadSpeedMBPerSec) + .filter((g) => Boolean(g)) as number[], + ); + + const labels = bins.map((b) => `${b.x0}-${b.x1} MB/sec`); + const values = bins.map((b) => b.length); // count per bin + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayLoads.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayLoads.tsx new file mode 100644 index 0000000000..f3c201dce9 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayLoads.tsx @@ -0,0 +1,45 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import React from "react"; + +export const GatewayLoads = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const binned = data.reduce( + (acc, g) => { + const score: "low" | "medium" | "high" | "offline" = + (g as any).performance_v2?.load || "offline"; + acc[score] += 1; + return acc; + }, + { offline: 0, low: 0, medium: 0, high: 0 }, + ); + + const labels = ["offline", "low", "medium", "high"]; + const values = Object.values(binned); + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayPingPercentage.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayPingPercentage.tsx new file mode 100644 index 0000000000..38bca5f5c5 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayPingPercentage.tsx @@ -0,0 +1,43 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import { bin } from "d3-array"; +import React from "react"; + +export const GatewayPingPercentage = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const binner = bin().domain([0, 1]).thresholds(10); // Number of bins + const bins = binner( + data.map((g) => g.last_probe?.outcome.wg?.ping_ips_performance_v4 || 0), + ); + + const labels = bins.map( + (b) => `${(b.x0 || 0) * 100}-${(b.x1 || 0) * 100}%`, + ); + const values = bins.map((b) => b.length); // count per bin + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayScores.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayScores.tsx new file mode 100644 index 0000000000..3daa8f4a67 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayScores.tsx @@ -0,0 +1,45 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import React from "react"; + +export const GatewayScores = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const binned = data.reduce( + (acc, g) => { + const score: "low" | "medium" | "high" | "offline" = + (g as any).performance_v2?.score || "offline"; + acc[score] += 1; + return acc; + }, + { offline: 0, low: 0, medium: 0, high: 0 }, + ); + + const labels = ["offline", "low", "medium", "high"]; + const values = Object.values(binned); + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayUptimePercentage.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayUptimePercentage.tsx new file mode 100644 index 0000000000..d84f707316 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayUptimePercentage.tsx @@ -0,0 +1,41 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import { bin } from "d3-array"; +import React from "react"; + +export const GatewayUptimePercentage = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const binner = bin().domain([0, 1]).thresholds(20); // Number of bins + const bins = binner(data.map((g) => Number.parseFloat(g.performance))); + + const labels = bins.map( + (b) => `${(b.x0 || 0) * 100}-${(b.x1 || 0) * 100}%`, + ); + const values = bins.map((b) => b.length); // count per bin + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayVersions.tsx b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayVersions.tsx new file mode 100644 index 0000000000..a6e9d0f3c7 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/graphs/GatewayVersions.tsx @@ -0,0 +1,49 @@ +import { useDVpnGatewaysTransformed } from "@/hooks/useGatewaysTransformed"; +import Box from "@mui/material/Box"; +import { BarChart } from "@mui/x-charts/BarChart"; +import { rollup } from "d3-array"; +import React from "react"; + +export const GatewayVersions = () => { + const { + query: { isSuccess, isError, data }, + } = useDVpnGatewaysTransformed(); + const binnedData = React.useMemo(() => { + if (!isSuccess || data === undefined) { + return undefined; + } + const versions = data.map((g) => g.build_information.build_version); + // count occurrences of each version + const versionCounts = rollup( + versions, + (v) => v.length, + (v) => v, // group by version string + ); + + const chartData = Array.from(versionCounts, ([version, count]) => ({ + version, + count, + })); + + const labels = chartData.map((d) => d.version); + const values = chartData.map((d) => d.count); + + return { labels, values }; + }, [data, isSuccess]); + + if (isError || !binnedData) { + return null; + } + + const { labels, values } = binnedData; + + return ( + + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/AppNavbar.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/AppNavbar.tsx new file mode 100644 index 0000000000..5f978551f4 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/AppNavbar.tsx @@ -0,0 +1,77 @@ +"use client"; + +import ColorModeIconDropdown from "@/theme/ColorModeIconDropdown"; +import MenuRoundedIcon from "@mui/icons-material/MenuRounded"; +import AppBar from "@mui/material/AppBar"; +import Stack from "@mui/material/Stack"; +import { tabsClasses } from "@mui/material/Tabs"; +import MuiToolbar from "@mui/material/Toolbar"; +import { styled } from "@mui/material/styles"; +import * as React from "react"; +import MenuButton from "./MenuButton"; +import SideMenuMobile from "./SideMenuMobile"; +import { SiteLogo } from "./SiteLogo"; + +const Toolbar = styled(MuiToolbar)({ + width: "100%", + padding: "12px", + display: "flex", + flexDirection: "column", + alignItems: "start", + justifyContent: "center", + gap: "12px", + flexShrink: 0, + [`& ${tabsClasses.flexContainer}`]: { + gap: "8px", + p: "8px", + pb: 0, + }, +}); + +export default function AppNavbar() { + const [open, setOpen] = React.useState(false); + + const toggleDrawer = (newOpen: boolean) => () => { + setOpen(newOpen); + }; + + return ( + + + + + + + + + + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeIconDropdown.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeIconDropdown.tsx new file mode 100644 index 0000000000..dd1ebef7d4 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeIconDropdown.tsx @@ -0,0 +1,91 @@ +"use client"; + +import DarkModeIcon from "@mui/icons-material/DarkModeRounded"; +import LightModeIcon from "@mui/icons-material/LightModeRounded"; +import Box from "@mui/material/Box"; +import IconButton, { type IconButtonOwnProps } from "@mui/material/IconButton"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import { useColorScheme } from "@mui/material/styles"; +import * as React from "react"; + +export default function ColorModeIconDropdown(props: IconButtonOwnProps) { + const { mode, systemMode, setMode } = useColorScheme(); + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + const handleMode = (targetMode: "system" | "light" | "dark") => () => { + setMode(targetMode); + handleClose(); + }; + if (!mode) { + return ( + ({ + verticalAlign: "bottom", + display: "inline-flex", + width: "2.25rem", + height: "2.25rem", + borderRadius: theme.shape.borderRadius, + border: "1px solid", + borderColor: theme.palette.divider, + })} + /> + ); + } + const resolvedMode = (systemMode || mode) as "light" | "dark"; + const icon = { + light: , + dark: , + }[resolvedMode]; + return ( + + + {icon} + + + + System + + + Light + + + Dark + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeSelect.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeSelect.tsx new file mode 100644 index 0000000000..839b3b6dc3 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/ColorModeSelect.tsx @@ -0,0 +1,29 @@ +"use client"; + +import MenuItem from "@mui/material/MenuItem"; +import Select, { type SelectProps } from "@mui/material/Select"; +import { useColorScheme } from "@mui/material/styles"; + +export default function ColorModeSelect(props: SelectProps) { + const { mode, setMode } = useColorScheme(); + if (!mode) { + return null; + } + return ( + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/Header.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/Header.tsx new file mode 100644 index 0000000000..5d7ebe12c2 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/Header.tsx @@ -0,0 +1,33 @@ +"use client"; + +import ColorModeIconDropdown from "@/theme/ColorModeIconDropdown"; +import Stack from "@mui/material/Stack"; +import NavbarBreadcrumbs from "./NavbarBreadcrumbs"; + +import type React from "react"; +import Search from "./Search"; + +const showSearch = false; + +export default function Header({ title }: { title?: React.ReactNode }) { + return ( + + + + {showSearch && } + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/MenuButton.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/MenuButton.tsx new file mode 100644 index 0000000000..c628996e25 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/MenuButton.tsx @@ -0,0 +1,23 @@ +"use client"; +import Badge, { badgeClasses } from "@mui/material/Badge"; +import IconButton, { type IconButtonProps } from "@mui/material/IconButton"; + +export interface MenuButtonProps extends IconButtonProps { + showBadge?: boolean; +} + +export default function MenuButton({ + showBadge = false, + ...props +}: MenuButtonProps) { + return ( + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/MenuContent.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/MenuContent.tsx new file mode 100644 index 0000000000..a38b662c88 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/MenuContent.tsx @@ -0,0 +1,60 @@ +"use client"; + +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Stack from "@mui/material/Stack"; +import NextLink from "next/link"; +import { usePathname } from "next/navigation"; + +import DoorSlidingOutlinedIcon from "@mui/icons-material/DoorSlidingOutlined"; +import HubIcon from "@mui/icons-material/Hub"; +import SettingsInputAntennaIcon from "@mui/icons-material/SettingsInputAntenna"; +import ViewModuleIcon from "@mui/icons-material/ViewModule"; +import WorkspacePremiumIcon from "@mui/icons-material/WorkspacePremium"; + +const mainListItemsAll = [ + { text: "Network Nodes", icon: , url: "/nodes" }, + { text: "dVPN Gateways", icon: , url: "/dvpn" }, + { text: "SOCKS5 NRs", icon: , url: "/socks5" }, + { + text: "zk-nym Signers", + icon: , + url: "/zk-nym-signers", + }, + { + text: "Nyx Chain Validators", + icon: , + url: "/validators", + }, +]; + +const mainListItems = [mainListItemsAll[0], mainListItemsAll[1]]; + +export default function MenuContent() { + const path = usePathname(); + return ( + + + {mainListItems.map((item, index) => ( + + + {item.icon} + + + + ))} + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/NavbarBreadcrumbs.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/NavbarBreadcrumbs.tsx new file mode 100644 index 0000000000..b74145778e --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/NavbarBreadcrumbs.tsx @@ -0,0 +1,37 @@ +"use client"; + +import NavigateNextRoundedIcon from "@mui/icons-material/NavigateNextRounded"; +import Breadcrumbs, { breadcrumbsClasses } from "@mui/material/Breadcrumbs"; +import Typography from "@mui/material/Typography"; +import { styled } from "@mui/material/styles"; +import type React from "react"; + +const StyledBreadcrumbs = styled(Breadcrumbs)(({ theme }) => ({ + margin: theme.spacing(1, 0), + [`& .${breadcrumbsClasses.separator}`]: { + color: theme.palette.action.disabled, + margin: 1, + }, + [`& .${breadcrumbsClasses.ol}`]: { + alignItems: "center", + }, +})); + +export default function NavbarBreadcrumbs({ + title, +}: { title?: React.ReactNode }) { + return ( + } + > + Dashboard + + {title || "Home"} + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/OptionsMenu.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/OptionsMenu.tsx new file mode 100644 index 0000000000..940ad44148 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/OptionsMenu.tsx @@ -0,0 +1,81 @@ +"use client"; + +import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded"; +import MoreVertRoundedIcon from "@mui/icons-material/MoreVertRounded"; +import Divider, { dividerClasses } from "@mui/material/Divider"; +import { listClasses } from "@mui/material/List"; +import ListItemIcon, { listItemIconClasses } from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Menu from "@mui/material/Menu"; +import MuiMenuItem from "@mui/material/MenuItem"; +import { paperClasses } from "@mui/material/Paper"; +import { styled } from "@mui/material/styles"; +import * as React from "react"; +import MenuButton from "./MenuButton"; + +const MenuItem = styled(MuiMenuItem)({ + margin: "2px 0", +}); + +export default function OptionsMenu() { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + return ( + + + + + + Profile + My account + + Add another account + Settings + + + Logout + + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/Search.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/Search.tsx new file mode 100644 index 0000000000..78d95c6bdb --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/Search.tsx @@ -0,0 +1,27 @@ +"use client"; + +import SearchRoundedIcon from "@mui/icons-material/SearchRounded"; +import FormControl from "@mui/material/FormControl"; +import InputAdornment from "@mui/material/InputAdornment"; +import OutlinedInput from "@mui/material/OutlinedInput"; + +export default function Search() { + return ( + + + + + } + inputProps={{ + "aria-label": "search", + }} + /> + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/SelectContent.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/SelectContent.tsx new file mode 100644 index 0000000000..3b0d2c2ed2 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/SelectContent.tsx @@ -0,0 +1,107 @@ +"use client"; + +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import ConstructionRoundedIcon from "@mui/icons-material/ConstructionRounded"; +import DevicesRoundedIcon from "@mui/icons-material/DevicesRounded"; +import SmartphoneRoundedIcon from "@mui/icons-material/SmartphoneRounded"; +import MuiAvatar from "@mui/material/Avatar"; +import Divider from "@mui/material/Divider"; +import MuiListItemAvatar from "@mui/material/ListItemAvatar"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import ListSubheader from "@mui/material/ListSubheader"; +import MenuItem from "@mui/material/MenuItem"; +import Select, { + type SelectChangeEvent, + selectClasses, +} from "@mui/material/Select"; +import { styled } from "@mui/material/styles"; +import * as React from "react"; + +const Avatar = styled(MuiAvatar)(({ theme }) => ({ + width: 28, + height: 28, + backgroundColor: theme.palette.background.paper, + color: theme.palette.text.secondary, + border: `1px solid ${theme.palette.divider}`, +})); + +const ListItemAvatar = styled(MuiListItemAvatar)({ + minWidth: 0, + marginRight: 12, +}); + +export default function SelectContent() { + const [company, setCompany] = React.useState(""); + + const handleChange = (event: SelectChangeEvent) => { + setCompany(event.target.value as string); + }; + + return ( + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenu.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenu.tsx new file mode 100644 index 0000000000..047e2b696d --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenu.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { SiteLogo } from "@/components/nav/SiteLogo"; +import Box from "@mui/material/Box"; +import Divider from "@mui/material/Divider"; +import MuiDrawer, { drawerClasses } from "@mui/material/Drawer"; +import { styled } from "@mui/material/styles"; +import MenuContent from "./MenuContent"; + +const drawerWidth = 240; + +const Drawer = styled(MuiDrawer)({ + width: drawerWidth, + flexShrink: 0, + boxSizing: "border-box", + mt: 10, + [`& .${drawerClasses.paper}`]: { + width: drawerWidth, + boxSizing: "border-box", + }, +}); + +export default function SideMenu() { + return ( + + + + + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenuMobile.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenuMobile.tsx new file mode 100644 index 0000000000..f6e866af5a --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/SideMenuMobile.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Divider from "@mui/material/Divider"; +import Drawer, { drawerClasses } from "@mui/material/Drawer"; +import Stack from "@mui/material/Stack"; +import MenuContent from "./MenuContent"; + +interface SideMenuMobileProps { + open: boolean | undefined; + toggleDrawer: (newOpen: boolean) => () => void; +} + +export default function SideMenuMobile({ + open, + toggleDrawer, +}: SideMenuMobileProps) { + return ( + theme.zIndex.drawer + 1, + [`& .${drawerClasses.paper}`]: { + backgroundImage: "none", + backgroundColor: "background.paper", + }, + }} + > + + + + + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/components/nav/SiteLogo.tsx b/nym-node-status-api/nym-node-status-ui/src/components/nav/SiteLogo.tsx new file mode 100644 index 0000000000..336ee3795c --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/components/nav/SiteLogo.tsx @@ -0,0 +1,15 @@ +import Link from "@mui/material/Link"; +import NextLink from "next/link"; + +export function SiteLogo() { + return ( + + Nym Node Status + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/context/queryContext.tsx b/nym-node-status-api/nym-node-status-ui/src/context/queryContext.tsx new file mode 100644 index 0000000000..43578ed17f --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/context/queryContext.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { type Client, createClient } from "@/client/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import type React from "react"; +import { createContext, useContext, useRef } from "react"; + +interface State { + client?: Client; +} + +export const QueryContext = createContext({ + client: undefined, +}); + +export const useQueryContext = (): React.ContextType => + useContext(QueryContext); + +export const QueryContextProvider = ({ + children, +}: { + children: React.ReactNode | React.ReactNode[]; +}) => { + const openApiClient = useRef( + createClient({ baseUrl: "https://mainnet-node-status-api.nymtech.cc" }), + ); + const queryClient = useRef(new QueryClient()); + + const state: State = { + client: openApiClient.current, + }; + + return ( + + + {children} + {/* Add devtools in development */} + {process.env.NODE_ENV === "development" && ( + + )} + + + ); +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/hooks/types.ts b/nym-node-status-api/nym-node-status-ui/src/hooks/types.ts new file mode 100644 index 0000000000..852d109cc7 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/hooks/types.ts @@ -0,0 +1,4 @@ +export interface Pagination { + pageIndex?: number; + pageSize?: number; +} diff --git a/nym-node-status-api/nym-node-status-ui/src/hooks/useAllNymNodes.ts b/nym-node-status-api/nym-node-status-ui/src/hooks/useAllNymNodes.ts new file mode 100644 index 0000000000..05bfa0a5cf --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/hooks/useAllNymNodes.ts @@ -0,0 +1,39 @@ +import { nymNodes } from "@/client/sdk.gen"; +import { useQueryContext } from "@/context/queryContext"; +import type { NymNode } from "@/hooks/useNymNodes"; +import { useQuery } from "@tanstack/react-query"; +import React from "react"; + +export const useAllNymNodes = () => { + const { client } = useQueryContext(); + const key = "nym-nodes-all"; + + const queryFn = React.useCallback(async (): Promise => { + const size = 100; + let busy = true; + let page = 0; + const allData = []; + do { + const { data, error } = await nymNodes({ client, query: { page, size } }); + if (error) throw error; + + if (data?.items) { + allData.push(...data.items); + } + + // keep querying until data is less than a page + if ((data?.items.length || 0) < size) { + busy = false; + } + page += 1; + } while (busy); + return allData; + }, [client]); + + const query = useQuery({ queryKey: [key], queryFn }); + + return { + key, + query, + }; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/hooks/useGateways.ts b/nym-node-status-api/nym-node-status-ui/src/hooks/useGateways.ts new file mode 100644 index 0000000000..d1508ce7da --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/hooks/useGateways.ts @@ -0,0 +1,20 @@ +import { getGatewaysOptions } from "@/client/@tanstack/react-query.gen"; +import { useQueryContext } from "@/context/queryContext"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +export const useDVpnGateways = () => { + const { client } = useQueryContext(); + const key = "gateways"; + + const query = useQuery({ + ...getGatewaysOptions({ + client, + }), + placeholderData: keepPreviousData, + }); + + return { + key, + query, + }; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/hooks/useGatewaysTransformed.ts b/nym-node-status-api/nym-node-status-ui/src/hooks/useGatewaysTransformed.ts new file mode 100644 index 0000000000..02c87b46af --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/hooks/useGatewaysTransformed.ts @@ -0,0 +1,47 @@ +import { getGateways } from "@/client/sdk.gen"; +import { useQueryContext } from "@/context/queryContext"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import React from "react"; + +export const useDVpnGatewaysTransformed = () => { + const { client } = useQueryContext(); + const key = "gateways"; + + const queryFn = React.useCallback(async () => { + const { data, error } = await getGateways({ client }); + if (error) throw error; + return (data || []).map((g) => { + const wg = g.last_probe?.outcome.wg as any; + const downloadSpeedMBPerSec = wg + ? Math.round( + (10 * ((wg?.downloaded_file_size_bytes_v4 || 0) / 1024 / 1024)) / + ((wg?.download_duration_milliseconds_v4 || 1) / 1000), + ) / 10 + : undefined; + const downloadSpeedIpv6MBPerSec = wg + ? Math.round( + (10 * ((wg?.downloaded_file_size_bytes_v6 || 0) / 1024 / 1024)) / + ((wg?.download_duration_milliseconds_v6 || 1) / 1000), + ) / 10 + : undefined; + return { + ...g, + extra: { + downloadSpeedMBPerSec, + downloadSpeedIpv6MBPerSec, + }, + }; + }); + }, [client]); + + const query = useQuery({ + queryKey: [key], + queryFn, + placeholderData: keepPreviousData, + }); + + return { + key, + query, + }; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/hooks/useNymNodes.ts b/nym-node-status-api/nym-node-status-ui/src/hooks/useNymNodes.ts new file mode 100644 index 0000000000..fa17795d7d --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/hooks/useNymNodes.ts @@ -0,0 +1,52 @@ +import type { + DescribedNodeType, + NodeDescription, + NodeGeoData, + NodeRewarding, + NymNodeData, + U32, +} from "@/client"; +import { nymNodesOptions } from "@/client/@tanstack/react-query.gen"; +import { useQueryContext } from "@/context/queryContext"; +import type { Pagination } from "@/hooks/types"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +// TODO: how to re-use autogenerated subtype +export type NymNode = { + accepted_tnc: boolean; + bonded: boolean; + bonding_address?: string | null; + description: NodeDescription; + geoip?: null | NodeGeoData; + identity_key: string; + ip_address: string; + node_id: U32; + node_type: DescribedNodeType; + original_pledge: number; + rewarding_details?: null | NodeRewarding; + self_description: NymNodeData; + total_stake: string; + uptime: number; +}; + +export const useNymNodes = (props?: Pagination) => { + const { client } = useQueryContext(); + const { pageIndex = 0, pageSize = 10 } = props || {}; + const key = "nym-nodes"; + + const query = useQuery({ + ...nymNodesOptions({ + client, + query: { + page: pageIndex, + size: pageSize, + }, + }), + placeholderData: keepPreviousData, + }); + + return { + key, + query, + }; +}; diff --git a/nym-node-status-api/nym-node-status-ui/src/layouts/LayoutWithNav.tsx b/nym-node-status-api/nym-node-status-ui/src/layouts/LayoutWithNav.tsx new file mode 100644 index 0000000000..e82ba5336c --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/layouts/LayoutWithNav.tsx @@ -0,0 +1,29 @@ +import Copyright from "@/components/Copyright"; +import AppNavbar from "@/components/nav/AppNavbar"; +import SideMenu from "@/components/nav/SideMenu"; +import Box from "@mui/material/Box"; +import { alpha } from "@mui/material/styles"; +import type React from "react"; + +export default function LayoutWithNav({ + children, +}: { children?: React.ReactNode }) { + return ( + + + + {/* Main content */} + ({ + flexGrow: 1, + backgroundColor: alpha(theme.palette.background.default, 1), + overflow: "auto", + })} + > + {children} + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/layouts/NestedLayoutWithHeader.tsx b/nym-node-status-api/nym-node-status-ui/src/layouts/NestedLayoutWithHeader.tsx new file mode 100644 index 0000000000..9b2e50bc45 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/layouts/NestedLayoutWithHeader.tsx @@ -0,0 +1,23 @@ +import Header from "@/components/nav/Header"; +import Stack from "@mui/material/Stack"; +import type React from "react"; + +export default function NestedLayoutWithHeader({ + children, + header, +}: { children?: React.ReactNode; header?: React.ReactNode }) { + return ( + +
+ {children} + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/theme/ColorModeIconDropdown.tsx b/nym-node-status-api/nym-node-status-ui/src/theme/ColorModeIconDropdown.tsx new file mode 100644 index 0000000000..dd1ebef7d4 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/theme/ColorModeIconDropdown.tsx @@ -0,0 +1,91 @@ +"use client"; + +import DarkModeIcon from "@mui/icons-material/DarkModeRounded"; +import LightModeIcon from "@mui/icons-material/LightModeRounded"; +import Box from "@mui/material/Box"; +import IconButton, { type IconButtonOwnProps } from "@mui/material/IconButton"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import { useColorScheme } from "@mui/material/styles"; +import * as React from "react"; + +export default function ColorModeIconDropdown(props: IconButtonOwnProps) { + const { mode, systemMode, setMode } = useColorScheme(); + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + const handleMode = (targetMode: "system" | "light" | "dark") => () => { + setMode(targetMode); + handleClose(); + }; + if (!mode) { + return ( + ({ + verticalAlign: "bottom", + display: "inline-flex", + width: "2.25rem", + height: "2.25rem", + borderRadius: theme.shape.borderRadius, + border: "1px solid", + borderColor: theme.palette.divider, + })} + /> + ); + } + const resolvedMode = (systemMode || mode) as "light" | "dark"; + const icon = { + light: , + dark: , + }[resolvedMode]; + return ( + + + {icon} + + + + System + + + Light + + + Dark + + + + ); +} diff --git a/nym-node-status-api/nym-node-status-ui/src/theme/index.tsx b/nym-node-status-api/nym-node-status-ui/src/theme/index.tsx new file mode 100644 index 0000000000..4773499620 --- /dev/null +++ b/nym-node-status-api/nym-node-status-ui/src/theme/index.tsx @@ -0,0 +1,27 @@ +import { ThemeProvider, createTheme } from "@mui/material/styles"; +import * as React from "react"; + +interface AppThemeProps { + children: React.ReactNode; + // themeComponents?: ThemeOptions["components"]; +} + +export default function AppTheme(props: AppThemeProps) { + const { children } = props; + const theme = React.useMemo(() => { + return createTheme({ + colorSchemes: { + dark: true, + light: true, + }, + typography: { + fontFamily: "system-ui, sans-serif", + }, + }); + }, []); + return ( + + {children} + + ); +}