From 7147db1e54f1cda2f1f77021ea5d7445eba1dc0c Mon Sep 17 00:00:00 2001 From: Ed Castro <ed.sdr@outlook.com> Date: Tue, 15 Oct 2024 17:41:53 -0300 Subject: [PATCH] feat: subnet filters Co-authored-by: Kelvin Steiner <me@steinerkelvin.dev> --- .../src/app/(pages)/modules/page.tsx | 6 +- .../src/app/(pages)/subnets/page.tsx | 54 ++++++++++---- .../app/components/subnet-view-controls.tsx | 70 +++++++++++++++++++ packages/api/src/router/subnet.ts | 45 ++++++++++++ packages/subspace/queries/index.ts | 47 ++++++++----- 5 files changed, 189 insertions(+), 33 deletions(-) create mode 100644 apps/commune-validator/src/app/components/subnet-view-controls.tsx diff --git a/apps/commune-validator/src/app/(pages)/modules/page.tsx b/apps/commune-validator/src/app/(pages)/modules/page.tsx index 631a834e..3f8b6e4e 100644 --- a/apps/commune-validator/src/app/(pages)/modules/page.tsx +++ b/apps/commune-validator/src/app/(pages)/modules/page.tsx @@ -1,11 +1,12 @@ import { Suspense } from "react"; +import type { Module } from "~/utils/types"; import { ModuleCard } from "~/app/components/module-card"; import { PaginationControls } from "~/app/components/pagination-controls"; import { ViewControls } from "~/app/components/view-controls"; import { api } from "~/trpc/server"; -export default async function Page({ +export default async function ModulesPage({ searchParams, }: { searchParams: { page?: string; sortBy?: string; order?: string }; @@ -17,7 +18,6 @@ export default async function Page({ const { modules, metadata } = await api.module.paginatedAll({ page: currentPage, limit: 24, - // @ts-expect-error - TS doesn't know about sortBy for some reason sortBy: sortBy, order: order, }); @@ -29,7 +29,7 @@ export default async function Page({ </Suspense> <div className="mb-16 grid w-full animate-fade-up grid-cols-1 gap-4 backdrop-blur-md animate-delay-700 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> {modules.length ? ( - modules.map((module) => ( + modules.map((module: Module) => ( <ModuleCard id={module.id} key={module.id} diff --git a/apps/commune-validator/src/app/(pages)/subnets/page.tsx b/apps/commune-validator/src/app/(pages)/subnets/page.tsx index e23552d6..a5c55dcb 100644 --- a/apps/commune-validator/src/app/(pages)/subnets/page.tsx +++ b/apps/commune-validator/src/app/(pages)/subnets/page.tsx @@ -1,19 +1,49 @@ +import { Suspense } from "react"; + +import type { Subnet } from "~/utils/types"; +import { PaginationControls } from "~/app/components/pagination-controls"; import SubnetCard from "~/app/components/subnet-card"; +import { SubnetViewControls } from "~/app/components/subnet-view-controls"; import { api } from "~/trpc/server"; -export default async function SubnetsPage() { - const data = await api.subnet.all(); +export default async function SubnetsPage({ + searchParams, +}: { + searchParams: { page?: string; sortBy?: string; order?: string }; +}) { + const currentPage = Number(searchParams.page) || 1; + const sortBy = searchParams.sortBy ?? "id"; + const order = searchParams.order === "desc" ? "desc" : "asc"; + + const { subnets, metadata } = await api.subnet.paginatedAll({ + page: currentPage, + limit: 24, + sortBy: sortBy, + order: order, + }); return ( - <div className="mb-4 flex w-full flex-col gap-4"> - {data.map((subnet) => ( - <SubnetCard - key={subnet.id} - founderAddress={subnet.founder} - id={subnet.netuid} - name={subnet.name} - /> - ))} - </div> + <> + <Suspense fallback={<div>Loading view controls...</div>}> + <SubnetViewControls /> + </Suspense> + <div className="mb-4 flex w-full flex-col gap-4"> + {subnets.length ? ( + subnets.map((subnet: Subnet) => ( + <SubnetCard + key={subnet.id} + founderAddress={subnet.founder} + id={subnet.netuid} + name={subnet.name} + /> + )) + ) : ( + <p>No subnets found</p> + )} + </div> + <Suspense fallback={<div>Loading...</div>}> + <PaginationControls totalPages={metadata.totalPages} /> + </Suspense> + </> ); } diff --git a/apps/commune-validator/src/app/components/subnet-view-controls.tsx b/apps/commune-validator/src/app/components/subnet-view-controls.tsx new file mode 100644 index 00000000..eb3a2327 --- /dev/null +++ b/apps/commune-validator/src/app/components/subnet-view-controls.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +type SortField = + | "id" + | "founderShare" + | "incentiveRatio" + | "proposalRewardTreasuryAllocation" + | "minValidatorStake" + | "createdAt"; + +type SortOrder = "asc" | "desc"; + +const sortFieldLabels: Record<SortField, string> = { + id: "ID", + founderShare: "Founder Share", + incentiveRatio: "Incentive Ratio", + proposalRewardTreasuryAllocation: "Proposal Reward Alloc.", + minValidatorStake: "Min. Vali. Stake", + createdAt: "Creation Date", +}; + +export function SubnetViewControls() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [sortField, setSortField] = useState<SortField>( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (searchParams.get("sortBy") as SortField) ?? "id", + ); + const [sortOrder, setSortOrder] = useState<SortOrder>( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (searchParams.get("order") as SortOrder) ?? "asc", + ); + + useEffect(() => { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set("sortBy", sortField); + newSearchParams.set("order", sortOrder); + router.push(`?${newSearchParams.toString()}`, { scroll: false }); + }, [sortField, sortOrder, router, searchParams]); + + const handleSortChange = (field: SortField) => { + const newOrder = + field === sortField && sortOrder === "asc" ? "desc" : "asc"; + setSortField(field); + setSortOrder(newOrder); + }; + + return ( + <div className="mb-4 flex w-full animate-fade-down flex-col items-center justify-between gap-2 border-b border-white/20 pb-4 animate-delay-200 md:flex-row"> + <span className="w-full text-white">Sort by:</span> + {(Object.keys(sortFieldLabels) as SortField[]).map((field) => ( + <button + key={field} + onClick={() => handleSortChange(field)} + className={`w-full py-1 text-sm ${ + sortField === field + ? "border border-cyan-500 bg-cyan-500/20 text-white" + : "border border-white/20 bg-[#898989]/5 text-gray-300 hover:bg-gray-600/50" + }`} + > + {sortFieldLabels[field]}{" "} + {sortField === field && (sortOrder === "asc" ? "↑" : "↓")} + </button> + ))} + </div> + ); +} diff --git a/packages/api/src/router/subnet.ts b/packages/api/src/router/subnet.ts index ba7845c8..1015430b 100644 --- a/packages/api/src/router/subnet.ts +++ b/packages/api/src/router/subnet.ts @@ -23,6 +23,51 @@ export const subnetRouter = { where: eq(subnetDataSchema.id, input.id), }); }), + paginatedAll: publicProcedure + .input( + z.object({ + page: z.number().int().positive().default(1), + limit: z.number().int().positive().max(100).default(50), + sortBy: z + .enum([ + "id", + "founderShare", + "incentiveRatio", + "proposalRewardTreasuryAllocation", + "minValidatorStake", + "createdAt", + ]) + .default("id"), + order: z.enum(["asc", "desc"]).default("asc"), + }), + ) + .query(async ({ ctx, input }) => { + const { page, limit, sortBy, order } = input; + const offset = (page - 1) * limit; + + const subnets = await ctx.db.query.subnetDataSchema.findMany({ + limit: limit, + offset: offset, + orderBy: (subnetData, { asc, desc }) => [ + order === "asc" ? asc(subnetData[sortBy]) : desc(subnetData[sortBy]), + ], + }); + + const totalCount = await ctx.db + .select({ count: sql`count(*)` }) + .from(subnetDataSchema) + .then((result) => Number(result[0]?.count)); + + return { + subnets, + metadata: { + currentPage: page, + pageSize: limit, + totalCount, + totalPages: Math.ceil(totalCount / limit), + }, + }; + }), byUserSubnetData: publicProcedure .input(z.object({ userKey: z.string() })) .query(async ({ ctx, input }) => { diff --git a/packages/subspace/queries/index.ts b/packages/subspace/queries/index.ts index 0ea61821..b740342d 100644 --- a/packages/subspace/queries/index.ts +++ b/packages/subspace/queries/index.ts @@ -21,12 +21,12 @@ import type { } from "@commune-ts/utils"; import { checkSS58, - STAKE_OUT_DATA_SCHEMA, GOVERNANCE_CONFIG_SCHEMA, isSS58, - STAKE_FROM_SCHEMA, MODULE_BURN_CONFIG_SCHEMA, NetworkSubnetConfigSchema, + STAKE_FROM_SCHEMA, + STAKE_OUT_DATA_SCHEMA, SUBSPACE_MODULE_SCHEMA, } from "@commune-ts/types"; import { @@ -36,7 +36,6 @@ import { standardizeUidToSS58address, } from "@commune-ts/utils"; - export { ApiPromise }; // == chain == @@ -348,7 +347,7 @@ export async function queryStakeOutCORRECT( if (!response.ok) { throw new Error("Failed to fetch data"); } - const stakeOutData = STAKE_OUT_DATA_SCHEMA.parse(await response.json()) + const stakeOutData = STAKE_OUT_DATA_SCHEMA.parse(await response.json()); return stakeOutData; } @@ -521,8 +520,9 @@ export async function queryUserTotalStaked( * @param netuidWhitelist if empty, modules from all subnets are returned */ - -export async function querySubnetParams(api: Api): Promise<NetworkSubnetConfig[]> { +export async function querySubnetParams( + api: Api, +): Promise<NetworkSubnetConfig[]> { const subnetProps: SubspaceStorageName[] = [ "subnetNames", "immunityPeriod", @@ -550,7 +550,6 @@ export async function querySubnetParams(api: Api): Promise<NetworkSubnetConfig[] const subnetInfo = await queryChain(api, props); const subnetNames = subnetInfo.subnetNames; - const subnets: NetworkSubnetConfig[] = []; for (const [netuid, _] of Object.entries(subnetNames)) { const subnet: NetworkSubnetConfig = NetworkSubnetConfigSchema.parse({ @@ -566,13 +565,18 @@ export async function querySubnetParams(api: Api): Promise<NetworkSubnetConfig[] trustRatio: subnetInfo.trustRatio[netuid]!, maxWeightAge: subnetInfo.maxWeightAge[netuid]!, bondsMovingAverage: subnetInfo.bondsMovingAverage[netuid], - maximumSetWeightCallsPerEpoch: subnetInfo.maximumSetWeightCallsPerEpoch[netuid], + maximumSetWeightCallsPerEpoch: + subnetInfo.maximumSetWeightCallsPerEpoch[netuid], minValidatorStake: subnetInfo.minValidatorStake[netuid]!, maxAllowedValidators: subnetInfo.maxAllowedValidators[netuid], - moduleBurnConfig: MODULE_BURN_CONFIG_SCHEMA.parse(subnetInfo.moduleBurnConfig[netuid]), + moduleBurnConfig: MODULE_BURN_CONFIG_SCHEMA.parse( + subnetInfo.moduleBurnConfig[netuid], + ), subnetMetadata: subnetInfo.subnetMetadata[netuid], netuid: netuid, - subnetGovernanceConfig: GOVERNANCE_CONFIG_SCHEMA.parse(subnetInfo.subnetGovernanceConfig[netuid]), + subnetGovernanceConfig: GOVERNANCE_CONFIG_SCHEMA.parse( + subnetInfo.subnetGovernanceConfig[netuid], + ), subnetEmission: subnetInfo.subnetEmission[netuid], }); subnets.push(subnet); @@ -582,7 +586,7 @@ export async function querySubnetParams(api: Api): Promise<NetworkSubnetConfig[] export function keyStakeFrom( targetKey: SS58Address, - stakeFromStorage: Map<SS58Address, Map<SS58Address, bigint>> + stakeFromStorage: Map<SS58Address, Map<SS58Address, bigint>>, ) { const stakerMap = stakeFromStorage.get(targetKey); let totalStake = 0n; @@ -618,13 +622,17 @@ export async function queryRegisteredModulesInfo( "dividends", "delegationFee", "stakeFrom", - ] + ]; - const extraPropsQuery: { subspaceModule: SubspaceStorageName[] } = { subspaceModule: moduleProps } + const extraPropsQuery: { subspaceModule: SubspaceStorageName[] } = { + subspaceModule: moduleProps, + }; const modulesInfo = await queryChain(api, extraPropsQuery, netuid); const processedModules = standardizeUidToSS58address(modulesInfo, uidToSS58); const moduleMap: SubspaceModule[] = []; - const parsedStakeFromStorage = STAKE_FROM_SCHEMA.parse({ stakeFromStorage: processedModules.stakeFrom }); + const parsedStakeFromStorage = STAKE_FROM_SCHEMA.parse({ + stakeFromStorage: processedModules.stakeFrom, + }); for (const uid of Object.keys(uidToSS58)) { const moduleKey = uidToSS58[uid]; @@ -646,10 +654,13 @@ export async function queryRegisteredModulesInfo( incentive: processedModules.incentive[moduleKey], dividends: processedModules.dividends[moduleKey], delegationFee: processedModules.delegationFee[moduleKey], - totalStaked: keyStakeFrom(moduleKey, parsedStakeFromStorage.stakeFromStorage), - totalStakers: parsedStakeFromStorage.stakeFromStorage.get(moduleKey)?.size ?? 0, - - }) + totalStaked: keyStakeFrom( + moduleKey, + parsedStakeFromStorage.stakeFromStorage, + ), + totalStakers: + parsedStakeFromStorage.stakeFromStorage.get(moduleKey)?.size ?? 0, + }); moduleMap.push(module); } return moduleMap;