From 815b9b1ce109e466a8bf5f62b5acd2e0517fe4a9 Mon Sep 17 00:00:00 2001 From: MananTank Date: Mon, 10 Feb 2025 20:05:31 +0000 Subject: [PATCH] [NEB-92] Dashboard: Add Nebula Analytics dashboard (#6198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR-Codex overview This PR primarily focuses on enhancing the `Nebula` analytics functionality by adding new components and improving data handling for analytics. It introduces new utilities for managing date ranges and search parameters, along with updates to the UI for better user interaction. ### Detailed summary - Added `getNebulaAnalyticsRangeFromSearchParams` function in `utils.ts`. - Updated `ProjectTabs` to remove `isOnNebulaWaitList` prop. - Introduced `normalizeTime` function in `time.ts` for date normalization. - Created `NebulaAnalyticsFilter` component for filtering analytics data. - Implemented `NebulaAnalyticsPage` and `NebulaAnalyticDashboard` for displaying analytics. - Enhanced `DateRangeSelector` with popover alignment options. - Added `NebulaAnalyticsDashboardUI` for rendering analytics data and charts. - Integrated `responsive-rsc` for responsive search parameters. - Updated `package.json` to include `responsive-rsc` dependency. - Removed calls to `getTeamNebulaWaitList` in `layout.tsx` and associated logic. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- apps/dashboard/package.json | 1 + .../@/components/ui/DatePickerWithRange.tsx | 7 +- .../components/transactions-table.tsx | 5 +- .../team/[team_slug]/(team)/~/nebula/page.tsx | 32 ++- .../[team_slug]/[project_slug]/layout.tsx | 6 +- .../analytics/fetch-nebula-analytics.tsx | 52 ++++ .../analytics/nebula-analytics-filter.tsx | 53 ++++ .../analytics/nebula-analytics-ui.stories.tsx | 115 ++++++++ .../analytics/nebula-analytics-ui.tsx | 257 ++++++++++++++++++ .../nebula/components/analytics/utils.ts | 13 + .../[project_slug]/nebula/page.tsx | 27 -- .../team/[team_slug]/[project_slug]/tabs.tsx | 12 +- .../analytics/date-range-selector.tsx | 19 +- apps/dashboard/src/lib/time.ts | 53 ++++ pnpm-lock.yaml | 14 + 15 files changed, 610 insertions(+), 56 deletions(-) create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/fetch-nebula-analytics.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-filter.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.stories.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/utils.ts delete mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/page.tsx create mode 100644 apps/dashboard/src/lib/time.ts diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index c8798e71178..0d322bb4b38 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -89,6 +89,7 @@ "react-table": "^7.8.0", "recharts": "2.15.1", "remark-gfm": "^4.0.0", + "responsive-rsc": "0.0.7", "server-only": "^0.0.1", "shiki": "1.27.0", "sonner": "^1.7.4", diff --git a/apps/dashboard/src/@/components/ui/DatePickerWithRange.tsx b/apps/dashboard/src/@/components/ui/DatePickerWithRange.tsx index a410737b434..793e1964fae 100644 --- a/apps/dashboard/src/@/components/ui/DatePickerWithRange.tsx +++ b/apps/dashboard/src/@/components/ui/DatePickerWithRange.tsx @@ -27,6 +27,7 @@ export function DatePickerWithRange(props: { header?: React.ReactNode; footer?: React.ReactNode; labelOverride?: string; + popoverAlign?: "start" | "end" | "center"; }) { const [screen, setScreen] = React.useState<"from" | "to">("from"); const { from, to, setFrom, setTo } = props; @@ -65,7 +66,11 @@ export function DatePickerWithRange(props: { {/* Popover */} - +
{!isValid && ( diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table.tsx index f0f9c21c1de..493eabfb013 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table.tsx @@ -52,6 +52,7 @@ import Link from "next/link"; import { type Dispatch, type SetStateAction, useMemo, useState } from "react"; import { toTokens } from "thirdweb"; import { FormLabel, LinkButton, Text } from "tw-components"; +import { normalizeTime } from "../../../../../../../../../../lib/time"; import { TransactionTimeline } from "./transaction-timeline"; export type EngineStatus = @@ -496,9 +497,7 @@ export function TransactionCharts(props: { if (!tx.queuedAt || !tx.status) { continue; } - const normalizedDate = new Date(tx.queuedAt); - normalizedDate.setHours(0, 0, 0, 0); // normalize time - const time = normalizedDate.getTime(); + const time = normalizeTime(new Date(tx.queuedAt)).getTime(); const entry = dayToTxCountMap.get(time) ?? {}; entry[tx.status] = (entry[tx.status] ?? 0) + 1; uniqueStatuses.add(tx.status); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/nebula/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/nebula/page.tsx index fa58d1872d0..da234f8fb76 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/nebula/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/nebula/page.tsx @@ -1,25 +1,45 @@ import { getTeamBySlug } from "@/api/team"; -import { redirect } from "next/navigation"; +import { getValidAccount } from "../../../../../account/settings/getAccount"; +import { getAuthToken } from "../../../../../api/lib/getAuthToken"; import { loginRedirect } from "../../../../../login/loginRedirect"; +import { NebulaAnalyticsPage } from "../../../[project_slug]/nebula/components/analytics/nebula-analytics-ui"; import { NebulaWaitListPage } from "../../../[project_slug]/nebula/components/nebula-waitlist-page"; export default async function Page(props: { params: Promise<{ team_slug: string; }>; + searchParams: Promise<{ + from: string | undefined | string[]; + to: string | undefined | string[]; + interval: string | undefined | string[]; + }>; }) { - const params = await props.params; - const team = await getTeamBySlug(params.team_slug); + const [params, searchParams] = await Promise.all([ + props.params, + props.searchParams, + ]); + + const [account, authToken, team] = await Promise.all([ + getValidAccount(), + getAuthToken(), + getTeamBySlug(params.team_slug), + ]); - if (!team) { + if (!team || !authToken) { loginRedirect(`/team/${params.team_slug}/~/nebula`); } - // if nebula access is already granted, redirect to nebula web app const hasNebulaAccess = team.enabledScopes.includes("nebula"); if (hasNebulaAccess) { - redirect("https://nebula.thirdweb.com"); + return ( + + ); } return ; diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx index d4a045fc4cc..d4bfa3170e2 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx @@ -1,5 +1,5 @@ import { getProjects } from "@/api/projects"; -import { getTeamNebulaWaitList, getTeams } from "@/api/team"; +import { getTeams } from "@/api/team"; import { notFound, redirect } from "next/navigation"; import { getValidAccount } from "../../../account/settings/getAccount"; import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client"; @@ -45,9 +45,6 @@ export default async function TeamLayout(props: { redirect(`/team/${params.team_slug}`); } - const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug)) - ?.onWaitlist; - return (
@@ -59,7 +56,6 @@ export default async function TeamLayout(props: { />
{props.children}
diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/fetch-nebula-analytics.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/fetch-nebula-analytics.tsx new file mode 100644 index 00000000000..55ff572b72b --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/fetch-nebula-analytics.tsx @@ -0,0 +1,52 @@ +import "server-only"; +import { unstable_cache } from "next/cache"; + +export type NebulaAnalyticsDataItem = { + date: string; + totalPromptTokens: number; + totalCompletionTokens: number; + totalSessions: number; + totalRequests: number; +}; + +export const fetchNebulaAnalytics = unstable_cache( + async (params: { + accountId: string; + authToken: string; + from: string; + to: string; + interval: "day" | "week"; + }) => { + const analyticsEndpoint = process.env.ANALYTICS_SERVICE_URL as string; + const url = new URL(`${analyticsEndpoint}/v1/nebula/usage`); + url.searchParams.set("accountId", params.accountId); + url.searchParams.set("from", params.from); + url.searchParams.set("to", params.to); + url.searchParams.set("interval", params.interval); + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${params.authToken}`, + }, + }); + + if (!res.ok) { + const error = await res.text(); + return { + ok: false as const, + error: error, + }; + } + + const resData = await res.json(); + + return { + ok: true as const, + data: resData.data as NebulaAnalyticsDataItem[], + }; + }, + ["nebula-analytics"], + { + revalidate: 60 * 60, // 1 hour + }, +); diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-filter.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-filter.tsx new file mode 100644 index 00000000000..86e9345f0e7 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-filter.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { + useResponsiveSearchParams, + useSetResponsiveSearchParams, +} from "responsive-rsc"; +import { DateRangeSelector } from "../../../../../../../components/analytics/date-range-selector"; +import { IntervalSelector } from "../../../../../../../components/analytics/interval-selector"; +import { + getNebulaFiltersFromSearchParams, + normalizeTimeISOString, +} from "../../../../../../../lib/time"; + +export function NebulaAnalyticsFilter() { + const responsiveSearchParams = useResponsiveSearchParams(); + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + + const { range, interval } = getNebulaFiltersFromSearchParams({ + from: responsiveSearchParams.from, + to: responsiveSearchParams.to, + interval: responsiveSearchParams.interval, + }); + + return ( +
+ { + setResponsiveSearchParams((v) => { + return { + ...v, + from: normalizeTimeISOString(newRange.from), + to: normalizeTimeISOString(newRange.to), + }; + }); + }} + /> + + { + setResponsiveSearchParams((v) => { + return { + ...v, + interval: newInterval, + }; + }); + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.stories.tsx new file mode 100644 index 00000000000..16a61969bf9 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.stories.tsx @@ -0,0 +1,115 @@ +import { TabButtons } from "@/components/ui/tabs"; +import type { Meta, StoryObj } from "@storybook/react"; +import { subDays } from "date-fns"; +import { useState } from "react"; +import { mobileViewport } from "../../../../../../../stories/utils"; +import type { NebulaAnalyticsDataItem } from "./fetch-nebula-analytics"; +import { NebulaAnalyticsDashboardUI } from "./nebula-analytics-ui"; + +const meta = { + title: "Nebula/Analytics", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + args: {}, +}; + +export const Mobile: Story = { + args: {}, + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +type VariantTab = "30-day" | "7-day" | "pending" | "60-day"; + +function Story() { + const [tab, setTab] = useState("60-day"); + return ( +
+
+

+ Story Variants +

+ setTab("60-day"), + isActive: tab === "60-day", + isEnabled: true, + }, + { + name: "30 Days", + onClick: () => setTab("30-day"), + isActive: tab === "30-day", + isEnabled: true, + }, + { + name: "7 Days", + onClick: () => setTab("7-day"), + isActive: tab === "7-day", + isEnabled: true, + }, + { + name: "Pending", + onClick: () => setTab("pending"), + isActive: tab === "pending", + isEnabled: true, + }, + ]} + /> +
+ + {tab === "60-day" && ( + + )} + + {tab === "30-day" && ( + + )} + + {tab === "7-day" && ( + + )} + + {tab === "pending" && ( + + )} +
+ ); +} + +function generateRandomNebulaAnalyticsData( + days: number, +): NebulaAnalyticsDataItem[] { + return Array.from({ length: days }, (_, i) => ({ + date: subDays(new Date(), i).toISOString(), + totalPromptTokens: randomInt(1000), + totalCompletionTokens: randomInt(1000), + totalSessions: randomInt(100), + totalRequests: randomInt(4000), + })); +} + +function randomInt(max: number) { + return Math.floor(Math.random() * max); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.tsx new file mode 100644 index 00000000000..78bdb8cf9db --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.tsx @@ -0,0 +1,257 @@ +import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; +import { SkeletonContainer } from "@/components/ui/skeleton"; +import { + ActivityIcon, + MessageCircleQuestionIcon, + MessageSquareIcon, + MessageSquareQuoteIcon, +} from "lucide-react"; +import { useMemo } from "react"; +import { + ResponsiveSearchParamsProvider, + ResponsiveSuspense, +} from "responsive-rsc"; +import { normalizeTimeISOString } from "../../../../../../../lib/time"; +import { + type NebulaAnalyticsDataItem, + fetchNebulaAnalytics, +} from "./fetch-nebula-analytics"; +import { NebulaAnalyticsFilter } from "./nebula-analytics-filter"; +import { getNebulaAnalyticsRangeFromSearchParams } from "./utils"; + +type ChartData = { + time: Date; + totalPromptTokens: number; + totalCompletionTokens: number; + totalSessions: number; + totalRequests: number; +}; + +export function NebulaAnalyticsPage(props: { + searchParams: { + from: string | undefined | string[]; + to: string | undefined | string[]; + interval: string | undefined | string[]; + }; + accountId: string; + authToken: string; +}) { + return ( + +
+
+
+

+ Nebula +

+
+ +
+
+ +
+ } + > + + +
+
+ ); +} + +async function NebulaAnalyticDashboard(props: { + accountId: string; + authToken: string; + searchParams: { + from: string | undefined | string[]; + to: string | undefined | string[]; + interval: string | undefined | string[]; + }; +}) { + const { range, interval } = getNebulaAnalyticsRangeFromSearchParams( + props.searchParams, + ); + + const res = await fetchNebulaAnalytics({ + accountId: props.accountId, + authToken: props.authToken, + from: normalizeTimeISOString(range.from), + to: normalizeTimeISOString(range.to), + interval, + }); + + if (!res.ok) { + return ( +
+
+

+ Failed to fetch Nebula analytics +

+

{res.error}

+
+
+ ); + } + + return ; +} + +export function NebulaAnalyticsDashboardUI(props: { + data: NebulaAnalyticsDataItem[]; + isPending: boolean; +}) { + const data = useMemo(() => { + const val: { + totalPromptTokens: number; + totalCompletionTokens: number; + totalSessions: number; + totalRequests: number; + chartData: ChartData[]; + } = { + totalPromptTokens: 0, + totalCompletionTokens: 0, + totalSessions: 0, + totalRequests: 0, + chartData: [], + }; + + for (const item of props.data) { + val.totalPromptTokens += item.totalPromptTokens; + val.totalCompletionTokens += item.totalCompletionTokens; + val.totalSessions += item.totalSessions; + val.totalRequests += item.totalRequests; + val.chartData.push({ + totalPromptTokens: item.totalPromptTokens, + totalCompletionTokens: item.totalCompletionTokens, + totalSessions: item.totalSessions, + totalRequests: item.totalRequests, + time: new Date(item.date), + }); + } + + return val; + }, [props.data]); + + return ( +
+
+ + + + +
+ +
+ +
+ + + + + + + +
+
+ ); +} + +function StatCard(props: { + title: string; + value: number; + icon: React.FC<{ className?: string }>; + isPending: boolean; +}) { + return ( +
+
+

{props.title}

+ +
+ ( +

+ {v} +

+ )} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/utils.ts b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/utils.ts new file mode 100644 index 00000000000..cecedcb8990 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/utils.ts @@ -0,0 +1,13 @@ +import { getNebulaFiltersFromSearchParams } from "../../../../../../../lib/time"; + +export function getNebulaAnalyticsRangeFromSearchParams(searchParams: { + from: string | undefined | string[]; + to: string | undefined | string[]; + interval: string | undefined | string[]; +}) { + return getNebulaFiltersFromSearchParams({ + from: searchParams.from, + to: searchParams.to, + interval: searchParams.interval, + }); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/page.tsx deleted file mode 100644 index b932c51e479..00000000000 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { getTeamBySlug } from "@/api/team"; -import { redirect } from "next/navigation"; -import { loginRedirect } from "../../../../login/loginRedirect"; -import { NebulaWaitListPage } from "./components/nebula-waitlist-page"; - -export default async function Page(props: { - params: Promise<{ - team_slug: string; - project_slug: string; - }>; -}) { - const params = await props.params; - const team = await getTeamBySlug(params.team_slug); - - if (!team) { - loginRedirect(`/team/${params.team_slug}/${params.project_slug}/nebula`); - } - - // if nebula access is already granted, redirect to nebula web app - const hasNebulaAccess = team.enabledScopes.includes("nebula"); - - if (hasNebulaAccess) { - redirect("https://nebula.thirdweb.com"); - } - - return ; -} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/tabs.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/tabs.tsx index dfa2212db26..90da40e513d 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/tabs.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/tabs.tsx @@ -4,9 +4,8 @@ import { TabPathLinks } from "@/components/ui/tabs"; export function ProjectTabs(props: { layoutPath: string; - isOnNebulaWaitList: boolean; }) { - const { layoutPath, isOnNebulaWaitList } = props; + const { layoutPath } = props; return ( void; + popoverAlign?: "start" | "end" | "center"; }) { const { range, setRange } = props; + const daysDiff = differenceInCalendarDays(range.to, range.from); + const matchingRange = + normalizeTime(range.to).getTime() === normalizeTime(new Date()).getTime() + ? durationPresets.find((preset) => preset.days === daysDiff) + : undefined; + + const rangeType = matchingRange?.id || range.type; + const rangeLabel = matchingRange?.name || range.label; return ( setRange({ from, @@ -35,7 +46,7 @@ export function DateRangeSelector(props: { header={
} - labelOverride={range.label} + labelOverride={rangeLabel} className="w-auto bg-card" /> ); diff --git a/apps/dashboard/src/lib/time.ts b/apps/dashboard/src/lib/time.ts new file mode 100644 index 00000000000..2f774160934 --- /dev/null +++ b/apps/dashboard/src/lib/time.ts @@ -0,0 +1,53 @@ +import { differenceInCalendarDays } from "date-fns"; +import { + type Range, + getLastNDaysRange, +} from "../components/analytics/date-range-selector"; + +export function normalizeTime(date: Date) { + const newDate = new Date(date); + newDate.setHours(1, 0, 0, 0); + return newDate; +} + +export function getNebulaFiltersFromSearchParams(params: { + from: string | undefined | string[]; + to: string | undefined | string[]; + interval: string | undefined | string[]; +}) { + const fromStr = params.from; + const toStr = params.to; + const defaultRange = getLastNDaysRange("last-30"); + + const range: Range = + fromStr && toStr && typeof fromStr === "string" && typeof toStr === "string" + ? { + from: normalizeTime(new Date(fromStr)), + to: normalizeTime(new Date(toStr)), + type: "custom", + } + : { + from: normalizeTime(defaultRange.from), + to: normalizeTime(defaultRange.to), + type: defaultRange.type, + }; + + const defaultInterval = + differenceInCalendarDays(range.to, range.from) > 30 + ? "week" + : ("day" as const); + + return { + range, + interval: + params.interval === "day" + ? ("day" as const) + : params.interval === "week" + ? ("week" as const) + : defaultInterval, + }; +} + +export function normalizeTimeISOString(date: Date) { + return normalizeTime(date).toISOString(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcf11ed3560..db5a24b9581 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,9 @@ importers: remark-gfm: specifier: ^4.0.0 version: 4.0.0 + responsive-rsc: + specifier: 0.0.7 + version: 0.0.7(next@15.1.6(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -13097,6 +13100,12 @@ packages: resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} engines: {node: '>=14.16'} + responsive-rsc@0.0.7: + resolution: {integrity: sha512-M0OxXCHJWL+QUUf3McCM5dd/7lmHXoUK1xoshVdInCGfRVq2L9MvhLiWfs6xcELutUwd6bjNQrTE3HLlpqFtZQ==} + peerDependencies: + next: '>=13' + react: '>=18' + restore-cursor@2.0.0: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} @@ -32160,6 +32169,11 @@ snapshots: dependencies: lowercase-keys: 3.0.0 + responsive-rsc@0.0.7(next@15.1.6(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): + dependencies: + next: 15.1.6(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + restore-cursor@2.0.0: dependencies: onetime: 2.0.1