diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index 3a45e1468..e408c7501 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -33,6 +33,7 @@ This change log covers only the frontend library (webui) of Open VSX. - Add a `userMenuContent` slot to `PageSettings.elements`: extra entries for the logged-in account menu, rendered above the admin entry. The slot receives a `MenuEntry` component to build entries with, so each entry is styled by the menu it appears in — the desktop and mobile menus style theirs differently, and a consumer cannot match both on its own - Add an `adminPages` slot to `PageSettings.elements`: extra admin dashboard pages, each declaring a name, icon, optional description and optional category, and each appearing in the side panel, as a card on the dashboard overview and as a route. Contributions are additive — a category name matching a built-in group appends to it, and a page whose path would shadow a built-in one is ignored - Widen the published API for consumers building their own pages: the request layer (`sendRequest`, `sendNonRetriableRequest`, `ErrorResponse`, `controllerFromSignal`), `MainContext`, `AppProviders`, `NotFound`, `createDefaultTheme` with the `MONO_FONT`/`NAVBAR_HEIGHT` tokens, the `createRoute`/`createAbsoluteURL`/`addQuery`/`formatCompactNumber`/`toRelativeTime` utils, the `useDebouncedCallback` and `useGridCursor` hooks, the navbar-chrome, search-focus and page-search-bar hooks, the category icon helpers, `ExtensionDetailRoutes`, and the `itemIcon`/`MenuItemText` building blocks for `userMenuContent` entries +- Add a weekly downloads card to the extension detail page, shown only when the registry reports download analytics as enabled: the last 7 days' downloads, a sparkline of the weekly totals for the year behind it, and the period the headline covers. Hovering moves a marker line and reads out that week instead, and the card shows a skeleton in the same shape while the series loads ### Changed diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index 5c8edd5d5..a67be0fa9 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -28,6 +28,8 @@ import { NamespaceMembershipList, PublisherInfo, RegistryVersion, + DownloadSeries, + DownloadSeriesInterval, SearchEntry, LoginProviders, ScanResultJson, @@ -94,6 +96,28 @@ export class ExtensionRegistryService { return createAbsoluteURL(arr); } + /** + * Fetches the download time series for an extension from the analytics endpoint. The endpoint + * only exists when download analytics are enabled server-side (otherwise it responds 404), so + * callers should gate on {@link RegistryVersion.analyticsEnabled}. `from`/`to` are UTC dates + * (yyyy-MM-dd); `from` is inclusive and `to` is exclusive. + */ + async getExtensionDownloadSeries( + abortController: AbortController, + params: { namespace: string; name: string; from?: string; to?: string; interval?: DownloadSeriesInterval } + ): Promise> { + const endpoint = createAbsoluteURL( + [this.serverUrl, 'api', params.namespace, params.name, 'analytics', 'downloads'], + [ + { key: 'from', value: params.from }, + { key: 'to', value: params.to }, + { key: 'interval', value: params.interval } + ] + ); + // Non-retriable: retries are owned by the TanStack query that calls this. + return sendNonRetriableRequest({ abortController, endpoint }); + } + async getNamespaceDetails(abortController: AbortController, name: string): Promise> { const endpoint = createAbsoluteURL([this.serverUrl, 'api', name, 'details']); return sendStrictRequest({ abortController, endpoint }); diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 5b4edfe2a..d39f5d888 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -340,8 +340,21 @@ export interface TargetPlatformVersion { export interface RegistryVersion { version: string; maxExtensionSize?: number; + analyticsEnabled?: boolean; } +/** One bucket of the download time series: `t` is the UTC bucket start (yyyy-MM-dd). */ +export interface DownloadSeriesPoint { + t: string; + count: number; +} + +export interface DownloadSeries { + points: DownloadSeriesPoint[]; +} + +export type DownloadSeriesInterval = 'day' | 'week' | 'month'; + export interface LoginProviders { loginProviders: Record; } diff --git a/webui/src/pages/extension-detail/extension-detail-overview.tsx b/webui/src/pages/extension-detail/extension-detail-overview.tsx index adf8f6538..1f2b11b64 100644 --- a/webui/src/pages/extension-detail/extension-detail-overview.tsx +++ b/webui/src/pages/extension-detail/extension-detail-overview.tsx @@ -24,6 +24,7 @@ import { Extension, ExtensionReference, VERSION_ALIASES } from '../../extension- import { ExtensionListRoutes } from '../extension-list/extension-list-routes'; import { ExtensionDetailRoutes } from './extension-detail-routes'; import { ExtensionDetailDownloadsMenu } from './extension-detail-downloads-menu'; +import { WeeklyDownloads } from './weekly-downloads'; export const ExtensionDetailOverview: FunctionComponent = props => { const [loading, setLoading] = useState(true); @@ -350,6 +351,7 @@ export const ExtensionDetailOverview: FunctionComponent + {renderVersionSection()} {otherAliases.length || extension.versionAlias.length ? ( diff --git a/webui/src/pages/extension-detail/use-extension-download-series.ts b/webui/src/pages/extension-detail/use-extension-download-series.ts new file mode 100644 index 000000000..270960c8b --- /dev/null +++ b/webui/src/pages/extension-detail/use-extension-download-series.ts @@ -0,0 +1,51 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { useContext } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { DateTime } from 'luxon'; +import { MainContext } from '../../context'; +import { controllerFromSignal } from '../../query-client'; +import { DownloadSeriesPoint } from '../../extension-registry-types'; + +const WEEKS = 52; + +/** + * Loads the last {@link WEEKS} whole weeks of *daily* downloads for an extension, ending today, as + * the react-query result (`data` is the ordered {@link DownloadSeriesPoint} array). The range is an + * exact multiple of 7 days so callers can fold it into whole weeks with nothing left over; daily + * granularity is what lets those weeks end on today rather than on the last complete calendar week. + * Gate with `options.enabled` on `RegistryVersion.analyticsEnabled`, since the endpoint 404s when + * analytics is disabled. + */ +export const useExtensionDownloadSeries = (namespace: string, name: string, options?: { enabled?: boolean }) => { + const { service } = useContext(MainContext); + return useQuery({ + queryKey: ['extension-downloads', namespace, name], + queryFn: async ({ signal }): Promise => { + const today = DateTime.utc().startOf('day'); + // `to` is exclusive, so today + 1 day includes today's (still-accruing) bucket. + const to = today.plus({ days: 1 }); + const from = to.minus({ weeks: WEEKS }); + const series = await service.getExtensionDownloadSeries(controllerFromSignal(signal), { + namespace, + name, + from: from.toFormat('yyyy-MM-dd'), + to: to.toFormat('yyyy-MM-dd'), + interval: 'day' + }); + return series.points; + }, + ...options + }); +}; diff --git a/webui/src/pages/extension-detail/weekly-downloads.tsx b/webui/src/pages/extension-detail/weekly-downloads.tsx new file mode 100644 index 000000000..39883afb7 --- /dev/null +++ b/webui/src/pages/extension-detail/weekly-downloads.tsx @@ -0,0 +1,189 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FunctionComponent, useContext, useMemo, useState } from 'react'; +import { Box, Skeleton, Typography, alpha, styled, useTheme } from '@mui/material'; +import { SparkLineChart } from '@mui/x-charts/SparkLineChart'; +import { lineClasses } from '@mui/x-charts/LineChart'; +import { chartsAxisHighlightClasses } from '@mui/x-charts/ChartsAxisHighlight'; +import { DateTime } from 'luxon'; +import { MainContext } from '../../context'; +import { Eyebrow } from '../../components/page-primitives'; +import { DownloadSeriesPoint, Extension } from '../../extension-registry-types'; +import { useExtensionDownloadSeries } from './use-extension-download-series'; + +/** Kept modest on purpose: the headline shares its row with the sparkline, which needs the width. */ +const DownloadsCount = styled(Typography)(({ theme }) => ({ + fontSize: '1.25rem', + lineHeight: 1.2, + fontWeight: 700, + color: theme.palette.text.primary, + fontVariantNumeric: 'tabular-nums' +})) as typeof Typography; + +const Period = styled(Typography)(({ theme }) => ({ + fontSize: '0.75rem', + lineHeight: 1.4, + color: theme.palette.text.secondary, + fontVariantNumeric: 'tabular-nums' +})) as typeof Typography; + +const DAY_AND_MONTH = { month: 'short', day: 'numeric' } as const; + +const WEEK_DAYS = 7; + +/** + * Tuned against the headline beside it: the row is bottom-aligned, so its height is the chart's and + * anything much taller leaves dead space above the number rather than a bigger curve. + */ +const CHART_HEIGHT_PX = 48; + +const asDate = (point: DownloadSeriesPoint): DateTime => DateTime.fromISO(point.t, { zone: 'utc' }); + +/** "Aug 21, 2026" for a single day, or "Aug 15 – Aug 21, 2026" across a week. */ +function formatPeriod(from: DownloadSeriesPoint, to: DownloadSeriesPoint): string | undefined { + const start = asDate(from); + const end = asDate(to); + if (!start.isValid || !end.isValid) { + return undefined; + } + + const endLabel = end.toLocaleString({ ...DAY_AND_MONTH, year: 'numeric' }); + return start.hasSame(end, 'day') ? endLabel : `${start.toLocaleString(DAY_AND_MONTH)} – ${endLabel}`; +} + +/** + * Folds a daily series into consecutive {@link WEEK_DAYS}-day totals, aligned so the last week ends + * on the most recent day. Any leading remainder shorter than a whole week is dropped, so every + * plotted point is a full week sharing no days with its neighbours — a rise or fall on the curve is + * a real week-on-week change. Oldest first. + */ +function weeklyTotals(daily: number[]): number[] { + const weeks: number[] = []; + for (let end = daily.length; end >= WEEK_DAYS; end -= WEEK_DAYS) { + let total = 0; + for (let day = end - WEEK_DAYS; day < end; day++) { + total += daily[day]; + } + weeks.unshift(total); + } + return weeks; +} + +/** + * The sidebar slot, shared by the loaded and loading states so neither shifts. Deliberately not a + * card: it sits flush with the resources group below it, which is styled the same way. + */ +const sectionSx = { + display: 'flex', + flexDirection: 'column', + flex: { xs: 'none', sm: 'none', md: 1, lg: 1, xl: 'none' }, + mb: { xs: 2, sm: 2, md: 0, lg: 0, xl: 2 } +} as const; + +/** Same shape and heights as the loaded section, so the sidebar does not jump when the series lands. */ +const LoadingCard: FunctionComponent = () => ( + + Weekly downloads + + + + + + +); + +/** + * "Weekly downloads" sidebar card: the downloads of the last 7 days, with a sparkline of the weekly + * totals for the year behind it — one point per week, so the headline is simply its last point. + * Hovering reads out that week instead. Renders nothing when download analytics are disabled + * server-side (the endpoint 404s) or when the extension has no downloads in the year. + */ +export const WeeklyDownloads: FunctionComponent<{ extension: Extension }> = ({ extension }) => { + const theme = useTheme(); + const { version } = useContext(MainContext); + const analyticsEnabled = version?.analyticsEnabled ?? false; + const [hovered, setHovered] = useState(undefined); + + const { data: points, isLoading } = useExtensionDownloadSeries(extension.namespace, extension.name, { + enabled: analyticsEnabled + }); + + const daily = useMemo(() => points ?? [], [points]); + const counts = useMemo(() => weeklyTotals(daily.map(point => point.count)), [daily]); + if (!analyticsEnabled) { + return null; + } + // `isLoading` is the first fetch only, and stays false while the query is disabled + if (isLoading) { + return ; + } + if (counts.length === 0 || !counts.some(count => count > 0)) { + return null; + } + + // the last week by default; whole weeks are taken from the end, so a short first week is dropped + const selected = hovered !== undefined && hovered < counts.length ? hovered : counts.length - 1; + const first = daily.length - counts.length * WEEK_DAYS + selected * WEEK_DAYS; + const period = formatPeriod(daily[first], daily[first + WEEK_DAYS - 1]); + // Reserve room for the busiest week, so the headline's width does not track its digit count and + // resize the sparkline beside it as the pointer moves. Data-derived, so it cannot be a class. + const reserved = `${Math.max(...counts).toLocaleString().length}ch`; + + return ( + + Weekly downloads + {period && {period}} + + {counts[selected].toLocaleString()} + + ({ min: -maxValue / 6, max: maxValue }) }} + clipAreaOffset={{ top: 2, bottom: 2 }} + showHighlight + // A non-'none' axis highlight is also what enables the axis listener, so + // the readout above tracks the pointer anywhere along the curve. + axisHighlight={{ x: 'line' }} + onHighlightedAxisChange={items => setHovered(items[0]?.dataIndex)} + slotProps={{ lineHighlight: { r: 4 } }} + color={theme.palette.secondary.main} + sx={{ + [`& .${lineClasses.area}`]: { opacity: 0.2 }, + [`& .${lineClasses.line}`]: { strokeWidth: 3 }, + [`& .${chartsAxisHighlightClasses.root}`]: { + stroke: theme.palette.secondary.main, + strokeDasharray: 'none', + strokeWidth: 2 + } + }} + /> + + + + ); +}; diff --git a/webui/test/unit/components/weekly-downloads.spec.tsx b/webui/test/unit/components/weekly-downloads.spec.tsx new file mode 100644 index 000000000..263bc3084 --- /dev/null +++ b/webui/test/unit/components/weekly-downloads.spec.tsx @@ -0,0 +1,166 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { describe, it, expect, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../support/test-providers'; +import { WeeklyDownloads } from '../../../src/pages/extension-detail/weekly-downloads'; +import { DownloadSeriesPoint, Extension, RegistryVersion } from '../../../src/extension-registry-types'; +import { ExtensionRegistryService } from '../../../src/extension-registry-service'; + +// The real chart pulls in SVG measurement APIs jsdom lacks; stub it so the test exercises the +// component's own logic (gating, the headline readout, and the series it feeds the chart). The +// buttons stand in for pointer movement along the axis, which is what the real chart reports +// through onHighlightedAxisChange. +vi.mock('@mui/x-charts/SparkLineChart', () => ({ + SparkLineChart: ({ + data, + onHighlightedAxisChange + }: { + data: number[]; + onHighlightedAxisChange?: (items: { axisId: string; dataIndex: number }[]) => void; + }) => ( +
+ {data.map((_, index) => ( +
+ ) +})); + +const extension = { namespace: 'redhat', name: 'java' } as unknown as Extension; +const analyticsEnabled: RegistryVersion = { version: '1.0.0', analyticsEnabled: true }; + +function points(counts: number[]): DownloadSeriesPoint[] { + return counts.map((count, i) => ({ t: `2026-01-${String(i + 1).padStart(2, '0')}`, count })); +} + +function serviceReturning(series: DownloadSeriesPoint[]): ExtensionRegistryService { + return { + getExtensionDownloadSeries: vi.fn().mockResolvedValue({ points: series }) + } as unknown as ExtensionRegistryService; +} + +// Two whole weeks, 1..14 downloads per day: week 0 covers Jan 1-7 (28) and week 1, the latest, +// covers Jan 8-14 (77). +const ascending = points(Array.from({ length: 14 }, (_, i) => i + 1)); + +describe('WeeklyDownloads', () => { + it('shows the last-7-days total and the trailing-week trend when analytics is enabled', async () => { + // 14 daily points of 1000 → two whole weeks of 7000 each + const service = serviceReturning(points(Array(14).fill(1000))); + renderWithProviders(, { + mainContext: { service, version: analyticsEnabled } + }); + + expect(await screen.findByText((7000).toLocaleString())).toBeInTheDocument(); + expect(screen.getByText(/weekly downloads/i)).toBeInTheDocument(); + expect(screen.getByTestId('sparkline')).toHaveAttribute('data-length', '2'); + expect(service.getExtensionDownloadSeries).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ namespace: 'redhat', name: 'java', interval: 'day' }) + ); + }); + + it('headlines the last week and labels the period it covers', async () => { + renderWithProviders(, { + mainContext: { service: serviceReturning(ascending), version: analyticsEnabled } + }); + + expect(await screen.findByText((77).toLocaleString())).toBeInTheDocument(); + expect(screen.getByText(/Jan 8.*Jan 14, 2026/)).toBeInTheDocument(); + }); + + it('reads out the hovered week, and returns to the last week when the pointer leaves', async () => { + renderWithProviders(, { + mainContext: { service: serviceReturning(ascending), version: analyticsEnabled } + }); + await screen.findByText((77).toLocaleString()); + + await userEvent.click(screen.getByRole('button', { name: 'hover 0' })); + + expect(screen.getByText((28).toLocaleString())).toBeInTheDocument(); + expect(screen.getByText(/Jan 1.*Jan 7, 2026/)).toBeInTheDocument(); + expect(screen.queryByText((77).toLocaleString())).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'hover out' })); + + expect(screen.getByText((77).toLocaleString())).toBeInTheDocument(); + expect(screen.getByText(/Jan 8.*Jan 14, 2026/)).toBeInTheDocument(); + }); + + it('reserves headline width for the busiest week, so the sparkline does not resize on hover', async () => { + // a quiet week of 7 then a busy one of 12,257,000 (ten chars with separators) + const uneven = points([...Array(7).fill(1), ...Array(7).fill(1751000)]); + renderWithProviders(, { + mainContext: { service: serviceReturning(uneven), version: analyticsEnabled } + }); + + // asserted on the style attribute: jsdom drops `ch` from the computed style + const reserved = `min-width: ${(12257000).toLocaleString().length}ch`; + const headline = await screen.findByText((12257000).toLocaleString()); + expect(headline.getAttribute('style')).toContain(reserved); + + // the reservation is unchanged while the single-digit week is being read out + await userEvent.click(screen.getByRole('button', { name: 'hover 0' })); + expect(screen.getByText('7').getAttribute('style')).toContain(reserved); + }); + + it('shows a skeleton while the first request is in flight, then the figures', async () => { + let resolve!: (value: { points: DownloadSeriesPoint[] }) => void; + const service = { + getExtensionDownloadSeries: vi.fn().mockReturnValue(new Promise(done => (resolve = done))) + } as unknown as ExtensionRegistryService; + renderWithProviders(, { + mainContext: { service, version: analyticsEnabled } + }); + + // the card's shell is already there, so the sidebar does not shift when the data lands + expect(screen.getByRole('status', { name: 'Loading weekly downloads' })).toBeInTheDocument(); + expect(screen.getByText(/weekly downloads/i)).toBeInTheDocument(); + expect(screen.queryByTestId('sparkline')).not.toBeInTheDocument(); + + resolve({ points: ascending }); + + expect(await screen.findByText((77).toLocaleString())).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.getByTestId('sparkline')).toBeInTheDocument(); + }); + + it('renders nothing (and never calls the endpoint) when analytics is disabled', () => { + const service = serviceReturning(points(Array(14).fill(1))); + renderWithProviders(, { + mainContext: { service, version: { version: '1.0.0', analyticsEnabled: false } } + }); + + expect(screen.queryByText(/weekly downloads/i)).not.toBeInTheDocument(); + expect(service.getExtensionDownloadSeries).not.toHaveBeenCalled(); + }); + + it('renders nothing when the extension has no downloads in the window', async () => { + const service = serviceReturning(points(Array(14).fill(0))); + renderWithProviders(, { + mainContext: { service, version: analyticsEnabled } + }); + + // the skeleton shows first, so wait for the card to settle on rendering nothing + await waitFor(() => expect(screen.queryByText(/weekly downloads/i)).not.toBeInTheDocument()); + expect(service.getExtensionDownloadSeries).toHaveBeenCalled(); + }); +});