From 1edb6db33906b838ba509f2880eb2c8c5eeacb4e Mon Sep 17 00:00:00 2001 From: anderdc Date: Wed, 13 May 2026 21:16:23 -0500 Subject: [PATCH] fix(dashboard): replace per-miner mirror fan-out with single bulk call Replaces N parallel mirror.gittensor.io/api/v1/miners//issues calls on every dashboard mount with a single call to /api/v1/dashboard/issues (see entrius/das-github-mirror#93). Eliminates the rate-limit cascade of 304s on /dashboard. Also fixes #1125 (Issues Resolved trend always 0): the resolved-issue predicate is now the conjunction (state=CLOSED, state_reason=COMPLETED, solving_pr.merged_at not null) and the resolved bucket uses solving_pr.merged_at instead of closed_at. Architecture: mirror is roster-blind, gittensor backend owns the miner roster, frontend blends. Watchlist and Miner Details continue to use the per-miner endpoint (low N, page-scoped). --- src/api/MirrorDashboardApi.ts | 27 +++++++++++ src/api/index.ts | 1 + src/api/models/MirrorDashboard.ts | 21 +++++++++ src/api/models/index.ts | 1 + src/pages/dashboard/dashboardData.ts | 48 +++++++------------- src/pages/dashboard/useDashboardData.ts | 60 ++++++++++++------------- 6 files changed, 94 insertions(+), 64 deletions(-) create mode 100644 src/api/MirrorDashboardApi.ts create mode 100644 src/api/models/MirrorDashboard.ts diff --git a/src/api/MirrorDashboardApi.ts b/src/api/MirrorDashboardApi.ts new file mode 100644 index 00000000..f55d7003 --- /dev/null +++ b/src/api/MirrorDashboardApi.ts @@ -0,0 +1,27 @@ +// Mirror dashboard API — single-call replacements for the dashboard's +// per-miner fan-out against `/miners//issues`. The mirror endpoints +// return roster-blind data; callers blend with the gittensor miner roster +// client-side to filter to subnet authors. +import { useMirrorApiQuery } from './ApiUtils'; +import { + type MirrorDashboardIssue, + type MirrorDashboardIssuesResponse, +} from './models'; + +/** + * Slim issue rows for dashboard trend aggregation. + * + * One bulk call replaces the N per-miner calls previously fanned out via + * `useMinersIssues`. Returns every issue created on or after `since`, plus + * every CLOSED issue closed on or after `since`. Authorship is preserved + * via `author_github_id` so the dashboard can filter to subnet miners. + */ +export const useMirrorDashboardIssues = (since: string, enabled?: boolean) => + useMirrorApiQuery( + 'useMirrorDashboardIssues', + `/dashboard/issues?since=${encodeURIComponent(since)}`, + { + enabled, + select: (data) => data?.issues ?? [], + }, + ); diff --git a/src/api/index.ts b/src/api/index.ts index 37a6f351..f521f980 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -4,6 +4,7 @@ export * from './DashboardApi'; export * from './IssuesApi'; export * from './MinerApi'; export * from './MirrorApi'; +export * from './MirrorDashboardApi'; export * from './PrsApi'; export * from './ReposApi'; export * from './SearchApi'; diff --git a/src/api/models/MirrorDashboard.ts b/src/api/models/MirrorDashboard.ts new file mode 100644 index 00000000..370052f1 --- /dev/null +++ b/src/api/models/MirrorDashboard.ts @@ -0,0 +1,21 @@ +// Mirror API (snake_case) — slim issue rows returned by +// `/api/v1/dashboard/issues`. The mirror is intentionally roster-blind: every +// row is returned regardless of author. The dashboard blends with the +// gittensor miner roster client-side to filter to subnet authors. + +export interface MirrorDashboardIssue { + repo_full_name: string; + issue_number: number; + author_github_id: string | null; + created_at: string; + closed_at: string | null; + state: 'OPEN' | 'CLOSED' | string; + state_reason: string | null; + solving_pr: { merged_at: string } | null; +} + +export interface MirrorDashboardIssuesResponse { + since: string; + generated_at: string; + issues: MirrorDashboardIssue[]; +} diff --git a/src/api/models/index.ts b/src/api/models/index.ts index e147ef1a..189604fc 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -2,4 +2,5 @@ export * from './Dashboard'; export * from './DatasetState'; export * from './Issues'; export * from './Miner'; +export * from './MirrorDashboard'; export * from './Configurations'; diff --git a/src/pages/dashboard/dashboardData.ts b/src/pages/dashboard/dashboardData.ts index 44d9c2e3..eb464f42 100644 --- a/src/pages/dashboard/dashboardData.ts +++ b/src/pages/dashboard/dashboardData.ts @@ -10,7 +10,7 @@ import { type CommitLog, type MinerEvaluation, - type MinerIssue, + type MirrorDashboardIssue, type Repository, } from '../../api'; import { @@ -106,7 +106,7 @@ interface FeaturedWorkConfig { const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; const WEEK_MS = 7 * DAY_MS; -const GITTENSOR_START_MS = Date.UTC(2025, 11, 1, 0, 0, 0); +export const GITTENSOR_START_MS = Date.UTC(2025, 11, 1, 0, 0, 0); const RANGE_CONFIG: Record< PresetTimeRange, @@ -170,39 +170,21 @@ export const getPreviousWindowBounds = ( }; }; -// Omitting `since` uses the mirror's default 35-day window and keeps the -// cache key stable across 1d/7d/35d ranges. -export const getMirrorSinceParam = ( - range: TrendTimeRange, -): string | undefined => - range === 'all' ? new Date(GITTENSOR_START_MS).toISOString() : undefined; - -// Dedupe by (repo, number) so an issue surfaced under multiple miners is counted once. -export const flattenMinerIssues = ( - responses: ReadonlyArray>, -): MinerIssue[] => { - const seen = new Set(); - const flattened: MinerIssue[] = []; - responses.forEach((batch) => { - batch.forEach((issue) => { - const key = `${issue.repo_full_name}#${issue.issue_number}`; - if (seen.has(key)) return; - seen.add(key); - flattened.push(issue); - }); - }); - return flattened; -}; - -export const isResolvedMinerIssue = (issue: MinerIssue): boolean => - issue.state === 'CLOSED' && issue.state_reason === 'COMPLETED'; +// A "truly resolved" issue: closed as completed AND the linked PR is merged. +// The conjunction matters — state_reason alone misses cases where GitHub +// doesn't set 'completed', and solving_pr.merged_at alone counts not-planned +// closures with stray PR links. +export const isResolvedMinerIssue = (issue: MirrorDashboardIssue): boolean => + issue.state === 'CLOSED' && + issue.state_reason === 'COMPLETED' && + !!issue.solving_pr?.merged_at; export const isResolvedInWindow = ( - issue: MinerIssue, + issue: MirrorDashboardIssue, window: WindowBounds, ): boolean => isResolvedMinerIssue(issue) && - isWithinWindow(toTimestamp(issue.closed_at), window); + isWithinWindow(toTimestamp(issue.solving_pr?.merged_at), window); const getUtcWeekStart = (timestamp: number) => { const date = new Date(timestamp); @@ -312,7 +294,7 @@ const formatDelta = ( export const buildDashboardTrendData = ( prs: CommitLog[], - issues: MinerIssue[], + issues: MirrorDashboardIssue[], range: TrendTimeRange, now = new Date(), ): { labels: string[]; series: DashboardTrendSeries[] } => { @@ -323,7 +305,7 @@ export const buildDashboardTrendData = ( ); const resolvedIssueTimestamps = issues .filter(isResolvedMinerIssue) - .map((issue) => toTimestamp(issue.closed_at)); + .map((issue) => toTimestamp(issue.solving_pr?.merged_at)); const buckets = buildTrendBuckets( [ ...mergedPrTimestamps, @@ -652,7 +634,7 @@ export const buildDashboardOverview = ( export const buildDashboardKpis = ( prs: CommitLog[], - issues: MinerIssue[], + issues: MirrorDashboardIssue[], range: TrendTimeRange, now = new Date(), ): DashboardKpi[] => { diff --git a/src/pages/dashboard/useDashboardData.ts b/src/pages/dashboard/useDashboardData.ts index 723d480e..95363480 100644 --- a/src/pages/dashboard/useDashboardData.ts +++ b/src/pages/dashboard/useDashboardData.ts @@ -12,7 +12,7 @@ import { useAllMiners, useAllPrs, useIssues, - useMinersIssues, + useMirrorDashboardIssues, useReposAndWeights, } from '../../api'; import { @@ -20,7 +20,7 @@ import { type DatasetState, type IssueBounty, type MinerEvaluation, - type MinerIssue, + type MirrorDashboardIssue, type Repository, } from '../../api/models'; import { @@ -30,8 +30,7 @@ import { buildFeaturedContributors, buildFeaturedWork, buildFeaturedDiscoveryContributors, - flattenMinerIssues, - getMirrorSinceParam, + GITTENSOR_START_MS, type TrendTimeRange, } from './dashboardData'; @@ -40,14 +39,12 @@ type DashboardDatasets = { miners: DatasetState; issues: DatasetState; repos: DatasetState; - minerIssues: DatasetState; + minerIssues: DatasetState; }; -const hasIssueActivity = (miner: MinerEvaluation): boolean => - (miner.totalSolvedIssues ?? 0) + - (miner.totalOpenIssues ?? 0) + - (miner.totalClosedIssues ?? 0) > - 0; +// Pinned once per module load — same `since` for every dashboard mount keeps +// the React Query cache key stable across renders and route remounts. +const DASHBOARD_ISSUES_SINCE_ISO = new Date(GITTENSOR_START_MS).toISOString(); export const useDashboardData = (range: TrendTimeRange) => { const prsQuery = useAllPrs(); @@ -55,31 +52,32 @@ export const useDashboardData = (range: TrendTimeRange) => { const issuesQuery = useIssues(); const reposQuery = useReposAndWeights(); - // Fan-out per-miner mirror calls; gate on issue activity to bound parallel requests. - const activeMinerGithubIds = useMemo( - () => - (minersQuery.data ?? []) - .filter(hasIssueActivity) - .map((m) => m.githubId) - .filter((id): id is string => Boolean(id)), - [minersQuery.data], + // Single bulk mirror call replaces the previous per-miner fan-out. + // The mirror is roster-blind; we filter to subnet authors below using the + // gittensor miner roster. + const dashboardIssuesQuery = useMirrorDashboardIssues( + DASHBOARD_ISSUES_SINCE_ISO, ); - const minerIssuesSince = getMirrorSinceParam(range); - const minerIssuesQueries = useMinersIssues( - activeMinerGithubIds, - activeMinerGithubIds.length > 0, - minerIssuesSince, - ); + const minerGithubIdSet = useMemo(() => { + const set = new Set(); + (minersQuery.data ?? []).forEach((m) => { + if (m.githubId) set.add(m.githubId); + }); + return set; + }, [minersQuery.data]); - const minerIssuesData = useMemo( - () => flattenMinerIssues(minerIssuesQueries.map((q) => q.data ?? [])), - [minerIssuesQueries], + const minerIssuesData = useMemo( + () => + (dashboardIssuesQuery.data ?? []).filter( + (issue) => + !!issue.author_github_id && + minerGithubIdSet.has(issue.author_github_id), + ), + [dashboardIssuesQuery.data, minerGithubIdSet], ); - const isMinerIssuesLoading = - activeMinerGithubIds.length > 0 && - minerIssuesQueries.some((q) => q.isLoading); - const isMinerIssuesError = minerIssuesQueries.some((q) => q.isError); + const isMinerIssuesLoading = dashboardIssuesQuery.isLoading; + const isMinerIssuesError = dashboardIssuesQuery.isError; const datasets: DashboardDatasets = { prs: {