From 054cf7d39d14b22b34c68e63883c96598bd58e5f Mon Sep 17 00:00:00 2001 From: Gaurav Pandey Date: Sun, 9 Aug 2026 12:52:37 +0200 Subject: [PATCH 1/2] feat: enhance adoption analytics with top docs and plugin adoption features - Introduced `PluginAdoptionTable` component to display plugin usage statistics, including events, users, and trends. - Updated `DauChart` to show new vs returning users in the daily active users chart. - Enhanced `AdoptionAnalyticsDashboard` to include top docs and plugin adoption data in the dashboard response. Signed-off-by: Gaurav Pandey --- .../AdoptionAnalyticsDashboardService.test.ts | 73 ++++++ .../AdoptionAnalyticsDashboardService.ts | 227 ++++++++++++++--- .../src/service/AdoptionAnalyticsDatabase.ts | 49 +++- .../adoption-analytics-common/src/types.ts | 60 ++++- plugins/adoption-analytics/README.md | 15 +- .../src/components/AdoptionAnalyticsPage.tsx | 16 +- .../src/components/DauChart.tsx | 40 ++- .../src/components/PluginAdoptionTable.tsx | 228 ++++++++++++++++++ .../src/components/SectionTabs.tsx | 2 +- .../src/components/TopDocsTable.tsx | 167 +++++++++++++ 10 files changed, 835 insertions(+), 42 deletions(-) create mode 100644 plugins/adoption-analytics/src/components/PluginAdoptionTable.tsx create mode 100644 plugins/adoption-analytics/src/components/TopDocsTable.tsx diff --git a/plugins/adoption-analytics-backend/src/service/AdoptionAnalyticsDashboardService.test.ts b/plugins/adoption-analytics-backend/src/service/AdoptionAnalyticsDashboardService.test.ts index 6b006f7..1b182c3 100644 --- a/plugins/adoption-analytics-backend/src/service/AdoptionAnalyticsDashboardService.test.ts +++ b/plugins/adoption-analytics-backend/src/service/AdoptionAnalyticsDashboardService.test.ts @@ -2,6 +2,7 @@ import { mockServices } from '@backstage/backend-test-utils'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { AdoptionAnalyticsDashboardService, + docsSiteFromPath, pageGroupFromPath, } from './AdoptionAnalyticsDashboardService'; import type { AdoptionAnalyticsDatabase } from './AdoptionAnalyticsDatabase'; @@ -42,11 +43,20 @@ function navigate( } function createService(events: RawEvent[]) { + // Mirrors the real query: earliest event date per user, across all + // history rather than the requested window. + const firstSeen = new Map(); + for (const e of events) { + const day = e.timestamp.toISOString().slice(0, 10); + const current = firstSeen.get(e.userRef); + if (current === undefined || day < current) firstSeen.set(e.userRef, day); + } return new AdoptionAnalyticsDashboardService({ logger: mockServices.logger.mock(), db: { getRawEvents: jest.fn().mockResolvedValue(events), getEntityCountSnapshots: jest.fn().mockResolvedValue([]), + getFirstSeenByUser: jest.fn().mockResolvedValue(firstSeen), } as unknown as AdoptionAnalyticsDatabase, catalog: catalogServiceMock({ entities: [] }), auth: mockServices.auth(), @@ -80,6 +90,69 @@ describe('pageGroupFromPath', () => { }); }); +describe('docsSiteFromPath', () => { + it('splits a docs path into its site ref and page', () => { + expect(docsSiteFromPath('/docs/default/component/foo/getting-started/')) // + .toEqual({ entityRef: 'component:default/foo', page: 'getting-started' }); + }); + + it('maps the site root to the "/" page', () => { + expect(docsSiteFromPath('/docs/default/component/foo')).toEqual({ + entityRef: 'component:default/foo', + page: '/', + }); + }); + + it('keeps nested pages distinct and drops query / hash', () => { + expect( + docsSiteFromPath('/docs/default/system/bar/api/v2?tab=1#top'), + ).toEqual({ entityRef: 'system:default/bar', page: 'api/v2' }); + }); + + it('returns null for non-docs paths', () => { + expect(docsSiteFromPath('/catalog/default/component/foo')).toBeNull(); + expect(docsSiteFromPath('/docs')).toBeNull(); + expect(docsSiteFromPath('Docs Home')).toBeNull(); + }); +}); + +describe('AdoptionAnalyticsDashboardService topDocs', () => { + it('rolls every page of a site into one row', async () => { + const service = createService([ + navigate('/docs/default/component/foo'), + navigate('/docs/default/component/foo/setup'), + navigate('/docs/default/component/foo/setup', 0, 'user:default/bob'), + navigate('/docs/default/component/bar'), + navigate('/catalog/default/component/foo'), + ]); + + const { topDocs } = await service.getDashboard('30d'); + + expect(topDocs).toEqual([ + { + entityRef: 'component:default/foo', + name: 'foo', + kind: 'component', + owner: null, + views: 3, + readers: 2, + pages: 2, + trendPct: null, + }, + { + entityRef: 'component:default/bar', + name: 'bar', + kind: 'component', + owner: null, + views: 1, + readers: 1, + pages: 1, + trendPct: null, + }, + ]); + }); +}); + describe('AdoptionAnalyticsDashboardService topPages', () => { it('groups navigation events by their first path segment', async () => { const service = createService([ diff --git a/plugins/adoption-analytics-backend/src/service/AdoptionAnalyticsDashboardService.ts b/plugins/adoption-analytics-backend/src/service/AdoptionAnalyticsDashboardService.ts index ad06cba..684c634 100644 --- a/plugins/adoption-analytics-backend/src/service/AdoptionAnalyticsDashboardService.ts +++ b/plugins/adoption-analytics-backend/src/service/AdoptionAnalyticsDashboardService.ts @@ -4,14 +4,17 @@ import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; import type { CatalogService } from '@backstage/plugin-catalog-node'; import type { ActiveUserSummary, + ActiveUsersSummary, EntityCountSnapshot, EntityGrowthPoint, AdoptionAnalyticsDashboard, AdoptionAnalyticsTimeRange, KpiWithDelta, + PluginAdoptionStat, SearchAnalytics, SearchTermStat, SearchVolumePoint, + TechDocsSiteStat, TopEntityStat, TopPageStat, WauSessionsBucket, @@ -33,6 +36,8 @@ const RANGE_DAYS: Record = { const TOP_ENTITIES_LIMIT = 8; +const TOP_DOCS_LIMIT = 8; + /** * How many page groups the payload carries. Larger than the entity limit * because the table paginates client-side rather than showing every row @@ -40,6 +45,9 @@ const TOP_ENTITIES_LIMIT = 8; */ const TOP_PAGES_LIMIT = 20; +/** Cap on the plugin table; a portal rarely runs more than a few dozen. */ +const PLUGIN_ADOPTION_LIMIT = 20; + /** * Days of history needed to compute {@link SharedKpis}. Driven by * `wauKpi`, which compares the trailing 7 days against the 7 before @@ -84,6 +92,10 @@ export class AdoptionAnalyticsDashboardService { const rawDays = Math.max(days * 2, wauHistoryDays); const raw = await db.getRawEvents(rawDays); const snapshots = await db.getEntityCountSnapshots(days * 2); + // Spans all retained history, not just `rawDays`, so the DAU split + // doesn't call a long-standing user "new" the first time they show + // up inside the selected window. + const firstSeenByUser = await db.getFirstSeenByUser(); const now = new Date(); const nowIso = now.toISOString(); @@ -112,7 +124,7 @@ export class AdoptionAnalyticsDashboardService { }, dau: { window: 'daily', - points: this.dauSeries(current, windowStart, now), + points: this.dauSeries(current, windowStart, now, firstSeenByUser), }, // Pass the full raw window: `wauSessions` has its own per-bucket // filter that walks back from `now`, and buckets earlier than the @@ -121,8 +133,10 @@ export class AdoptionAnalyticsDashboardService { wauSessions: this.wauSessions(raw, windowStart, now, range), entityGrowth: this.entityGrowth(snapshots, range), topEntities: await this.topEntities(current, previous), + topDocs: await this.topDocs(current, previous), topPages: this.topPages(current, previous), activeUsers: this.activeUsers(current), + plugins: this.pluginAdoption(current, previous), search: this.searchAnalytics(current, windowStart, now), }; } @@ -215,7 +229,8 @@ export class AdoptionAnalyticsDashboardService { events: RawEvent[], from: Date, to: Date, - ): Array<{ date: string; activeUsers: number }> { + firstSeenByUser: Map, + ): ActiveUsersSummary['points'] { const buckets = new Map>(); for (const day of eachDay(from, to)) { buckets.set(day, new Set()); @@ -225,10 +240,20 @@ export class AdoptionAnalyticsDashboardService { const set = buckets.get(day); if (set) set.add(e.userRef); } - return [...buckets.entries()].map(([date, users]) => ({ - date, - activeUsers: users.size, - })); + return [...buckets.entries()].map(([date, users]) => { + // Users missing from the map count as new so the two parts always + // sum back to `activeUsers`. + let newUsers = 0; + for (const user of users) { + if ((firstSeenByUser.get(user) ?? date) === date) newUsers += 1; + } + return { + date, + activeUsers: users.size, + newUsers, + returningUsers: users.size - newUsers, + }; + }); } private wauSessions( @@ -395,44 +420,86 @@ export class AdoptionAnalyticsDashboardService { if (top.length === 0) return []; + const enriched = await this.enrichEntities(top.map(([ref]) => ref)); + + return top.map(([ref, views]) => { + const parsed = safeParseRef(ref); + const entity = enriched.get(ref.toLowerCase()); + const owner = + (entity?.spec?.owner as string | undefined) ?? parsed.owner ?? null; + const prev = previousCounts.get(ref) ?? 0; + return { + entityRef: ref, + name: parsed.name, + kind: entity?.kind ?? parsed.kind, + owner, + views, + trendPct: prev === 0 ? null : pctChange(prev, views), + }; + }); + } + + // ---- Top TechDocs sites --------------------------------------------- + + private async topDocs( + current: RawEvent[], + previous: RawEvent[], + ): Promise { + const currentStats = countDocsViews(current); + const previousStats = countDocsViews(previous); + + const top = [...currentStats.entries()] + .sort((a, b) => b[1].views - a[1].views || a[0].localeCompare(b[0])) + .slice(0, TOP_DOCS_LIMIT); + + if (top.length === 0) return []; + + const enriched = await this.enrichEntities(top.map(([ref]) => ref)); + + return top.map(([ref, v]) => { + const parsed = safeParseRef(ref); + const entity = enriched.get(ref.toLowerCase()); + const prev = previousStats.get(ref)?.views ?? 0; + return { + entityRef: ref, + name: parsed.name, + kind: entity?.kind ?? parsed.kind, + owner: + (entity?.spec?.owner as string | undefined) ?? parsed.owner ?? null, + views: v.views, + readers: v.users.size, + pages: v.pages.size, + trendPct: prev === 0 ? null : pctChange(prev, v.views), + }; + }); + } + + /** + * Resolves kind/owner for the given entity refs. Enrichment failures + * are non-fatal: the tables fall back to the values parsed out of the + * ref itself rather than dropping rows. + */ + private async enrichEntities(refs: string[]): Promise> { const { catalog, auth, logger } = this.options; - let enriched: Map = new Map(); try { const credentials = await auth.getOwnServiceCredentials(); - const refs = top.map(([ref]) => ref); const { items } = await catalog.getEntitiesByRefs( { entityRefs: refs, fields: ['kind', 'metadata.name', 'spec.owner'] }, { credentials }, ); - enriched = new Map( + return new Map( items .filter((e): e is Entity => Boolean(e)) .map(e => [stringifyEntityRef(e).toLowerCase(), e]), ); } catch (err) { - // Non-fatal — we can still return views without owner enrichment. logger.warn( - `adoption-analytics-backend: failed to enrich top entities from catalog: ${ + `adoption-analytics-backend: failed to enrich entities from catalog: ${ (err as Error).message }`, ); + return new Map(); } - - return top.map(([ref, views]) => { - const parsed = safeParseRef(ref); - const entity = enriched.get(ref.toLowerCase()); - const owner = - (entity?.spec?.owner as string | undefined) ?? parsed.owner ?? null; - const prev = previousCounts.get(ref) ?? 0; - return { - entityRef: ref, - name: parsed.name, - kind: entity?.kind ?? parsed.kind, - owner, - views, - trendPct: prev === 0 ? null : pctChange(prev, views), - }; - }); } // ---- Top pages ------------------------------------------------------- @@ -457,6 +524,56 @@ export class AdoptionAnalyticsDashboardService { }) ); } + + // ---- Plugin adoption ------------------------------------------------- + + /** + * Usage per plugin. Events without a `pluginId` are dropped rather + * than bucketed as "unknown" — they come from captures that never set + * an analytics context, so a catch-all row would name a plugin nobody + * can act on. + */ + private pluginAdoption( + current: RawEvent[], + previous: RawEvent[], + ): PluginAdoptionStat[] { + const previousCounts = countPluginEvents(previous); + const byPlugin = new Map< + string, + { events: number; users: Set; last: number } + >(); + for (const e of current) { + const pluginId = e.pluginId?.trim(); + if (!pluginId) continue; + const t = e.timestamp.getTime(); + const cur = byPlugin.get(pluginId); + if (!cur) { + byPlugin.set(pluginId, { + events: 1, + users: new Set([e.userRef]), + last: t, + }); + } else { + cur.events += 1; + cur.users.add(e.userRef); + if (t > cur.last) cur.last = t; + } + } + + return [...byPlugin.entries()] + .sort((a, b) => b[1].events - a[1].events || a[0].localeCompare(b[0])) + .slice(0, PLUGIN_ADOPTION_LIMIT) + .map(([pluginId, v]) => { + const prev = previousCounts.get(pluginId) ?? 0; + return { + pluginId, + events: v.events, + users: v.users.size, + lastSeen: new Date(v.last).toISOString(), + trendPct: prev === 0 ? null : pctChange(prev, v.events), + }; + }); + } } // ---- Types the aggregator relies on ------------------------------------ @@ -511,6 +628,62 @@ function countPageViews(events: RawEvent[]): Map { return counts; } +function countPluginEvents(events: RawEvent[]): Map { + const counts = new Map(); + for (const e of events) { + const pluginId = e.pluginId?.trim(); + if (!pluginId) continue; + counts.set(pluginId, (counts.get(pluginId) ?? 0) + 1); + } + return counts; +} + +type DocsSiteCounts = { + views: number; + users: Set; + pages: Set; +}; + +function countDocsViews(events: RawEvent[]): Map { + const stats = new Map(); + for (const e of events) { + if (e.action !== 'navigate') continue; + const path = extractPath(e.subject) ?? e.pathname; + if (!path) continue; + const site = docsSiteFromPath(path); + if (!site) continue; + let cur = stats.get(site.entityRef); + if (!cur) { + cur = { views: 0, users: new Set(), pages: new Set() }; + stats.set(site.entityRef, cur); + } + cur.views += 1; + cur.users.add(e.userRef); + cur.pages.add(site.page); + } + return stats; +} + +/** + * Splits a TechDocs pathname into the site's entity ref and the page + * within it: `/docs/default/component/foo/getting-started/` yields + * `component:default/foo` and `getting-started`. The site root maps to + * the page `/`. Returns null for anything that isn't a docs path. + */ +export function docsSiteFromPath( + pathname: string, +): { entityRef: string; page: string } | null { + const clean = pathname.split(/[?#]/)[0].trim(); + if (!clean.startsWith('/')) return null; + const parts = clean.split('/').filter(Boolean); + if (parts.length < 4 || parts[0].toLowerCase() !== 'docs') return null; + const [, namespace, kind, name, ...rest] = parts; + return { + entityRef: `${kind.toLowerCase()}:${namespace.toLowerCase()}/${name.toLowerCase()}`, + page: rest.length === 0 ? '/' : rest.join('/').toLowerCase(), + }; +} + /** * Collapses a pathname to its first segment, e.g. `/docs/default/component/foo` * becomes `/docs` and `/` stays `/`. Returns null for values that aren't diff --git a/plugins/adoption-analytics-backend/src/service/AdoptionAnalyticsDatabase.ts b/plugins/adoption-analytics-backend/src/service/AdoptionAnalyticsDatabase.ts index f863bec..64afcb8 100644 --- a/plugins/adoption-analytics-backend/src/service/AdoptionAnalyticsDatabase.ts +++ b/plugins/adoption-analytics-backend/src/service/AdoptionAnalyticsDatabase.ts @@ -128,6 +128,25 @@ export class AdoptionAnalyticsDatabase { return Array.from(byDate.values()); } + /** + * Date (YYYY-MM-DD) of each user's first-ever event across all retained + * history. Needed to tell new users from returning ones: a windowed + * scan alone would label anyone whose first event lands in the window + * as new, even if they had been active for months before it. + */ + async getFirstSeenByUser(): Promise> { + const rows = (await this.db('insights_events') + .select('user_ref') + .min({ first_seen: 'timestamp' }) + .groupBy('user_ref')) as Array<{ + user_ref: string; + first_seen: Date | string | number; + }>; + return new Map( + rows.map(r => [r.user_ref, isoDate(new Date(r.first_seen))]), + ); + } + async getActiveUsers( window: 'daily' | 'weekly', days: number, @@ -139,6 +158,7 @@ export class AdoptionAnalyticsDatabase { user_ref: string; timestamp: Date | string; }>; + const firstSeen = await this.getFirstSeenByUser(); // Bucket per user per day, then aggregate to the requested window. // Doing this in JS keeps the query portable across SQLite/Postgres. @@ -153,7 +173,7 @@ export class AdoptionAnalyticsDatabase { const points: ActiveUsersSummary['points'] = []; if (window === 'daily') { for (const [date, users] of [...dailyBuckets.entries()].sort()) { - points.push({ date, activeUsers: users.size }); + points.push(splitNewReturning(date, date, users, firstSeen)); } } else { // Rolling 7-day window ending on each day that has any activity. @@ -166,7 +186,7 @@ export class AdoptionAnalyticsDatabase { for (const u of us) users.add(u); } } - points.push({ date: day, activeUsers: users.size }); + points.push(splitNewReturning(day, windowStart, users, firstSeen)); } } return { window, points }; @@ -263,3 +283,28 @@ function daysAgoFromIso(iso: string, days: number): string { d.setUTCDate(d.getUTCDate() - days); return isoDate(d); } + +/** + * Splits a bucket's active users into first-timers and returners. A user + * is new when their first-ever event date falls inside [from, to] — + * users missing from the map are treated as new so a bucket's parts + * always add up to its total. + */ +function splitNewReturning( + date: string, + from: string, + users: Set, + firstSeen: Map, +): ActiveUsersSummary['points'][number] { + let newUsers = 0; + for (const user of users) { + const first = firstSeen.get(user); + if (first === undefined || (first >= from && first <= date)) newUsers += 1; + } + return { + date, + activeUsers: users.size, + newUsers, + returningUsers: users.size - newUsers, + }; +} diff --git a/plugins/adoption-analytics-common/src/types.ts b/plugins/adoption-analytics-common/src/types.ts index fd3e27a..c7214b6 100644 --- a/plugins/adoption-analytics-common/src/types.ts +++ b/plugins/adoption-analytics-common/src/types.ts @@ -72,6 +72,14 @@ export interface ActiveUsersSummary { points: Array<{ date: string; activeUsers: number; + /** + * Active users whose first-ever recorded event falls in this bucket. + * Judged against all retained history, not just the selected range, + * so someone active last month never counts as new again. + */ + newUsers: number; + /** Active users that had already been seen before this bucket. */ + returningUsers: number; }>; } @@ -162,12 +170,56 @@ export interface ActiveUserSummary { eventCount: number; } +/** + * One row of the "Top TechDocs Sites" table. + * + * A "site" is the entity that owns the docs, so every page under + * `/docs/default/component/foo/**` rolls up into a single row — the + * useful question is which documentation gets read, not which heading + * inside it. + */ +export interface TechDocsSiteStat { + /** Entity ref of the documented entity, e.g. `component:default/foo`. */ + entityRef: string; + name: string; + kind: string; + owner: string | null; + /** Page views across the whole site in the current window. */ + views: number; + /** Distinct users who opened at least one page of the site. */ + readers: number; + /** Distinct pages read within the site. */ + pages: number; + /** Percentage change in views vs. the previous period. Null if unknown. */ + trendPct: number | null; +} + +/** + * One row of the "Plugin Adoption" table: how much a single Backstage + * plugin was used in the current window. + * + * Only events that carry an analytics `pluginId` are counted, so a + * plugin that never sets an analytics context stays invisible here + * even if its pages show up under {@link TopPageStat}. + */ +export interface PluginAdoptionStat { + /** Backstage plugin id, e.g. `catalog` or `techdocs`. */ + pluginId: string; + /** Events attributed to the plugin in the current window. */ + events: number; + /** Distinct users who triggered at least one of those events. */ + users: number; + /** ISO timestamp of the plugin's most recent event. */ + lastSeen: string; + /** Percentage change in events vs. the previous period. Null if unknown. */ + trendPct: number | null; +} + /** * Aggregated search-query statistics. */ export interface SearchTermStat { - /** Normalised query text (lowercased, trimmed). */ - query: string; + /** Normalised query text (lowercased, trimmed). */ query: string; /** Number of times the query was searched in the window. */ count: number; /** Number of distinct users who ran that query. */ @@ -213,10 +265,14 @@ export interface AdoptionAnalyticsDashboard { wauSessions: WauSessionsBucket[]; entityGrowth: EntityGrowthPoint[]; topEntities: TopEntityStat[]; + /** Most-read TechDocs sites in the current window, most-viewed first. */ + topDocs: TechDocsSiteStat[]; /** Most-visited page groups in the current window, most-viewed first. */ topPages: TopPageStat[]; /** Distinct users active in the current window, most-recent first. */ activeUsers: ActiveUserSummary[]; + /** Per-plugin usage in the current window, most-used first. */ + plugins: PluginAdoptionStat[]; /** Aggregated search-query statistics for the current window. */ search: SearchAnalytics; } diff --git a/plugins/adoption-analytics/README.md b/plugins/adoption-analytics/README.md index c4dd1fd..27d7c65 100644 --- a/plugins/adoption-analytics/README.md +++ b/plugins/adoption-analytics/README.md @@ -25,11 +25,16 @@ A KPI row plus three tabbed sections, with a 7 / 30 / 90-day range selector. Only Total Entities follows the range selector; the other three always describe "right now". Each card's sub-label states its baseline. -| Tab | Contents | -| --------- | --------------------------------------------------------------- | -| `users` | DAU chart, WAU + sessions chart, active-user list | -| `catalog` | Entity growth over time, top-viewed entities, top visited pages | -| `search` | Search volume over time, most-searched terms | +| Tab | Contents | +| --------- | --------------------------------------------------------------------- | +| `users` | DAU chart (new vs. returning), WAU + sessions chart, active-user list | +| `catalog` | Entity growth over time, top-viewed entities, top visited pages | +| `search` | Search volume over time, most-searched terms | +| `plugins` | Per-plugin events, distinct users, share of activity and trend | + +Plugin adoption only counts events that carry an analytics `pluginId`, so a +plugin that never sets an analytics context won't appear even if its routes +show up under top visited pages. The active-user list shows pseudonyms such as `user:masked/1a2b3c4d` unless the caller holds `adoption-analytics.users.read`. Pseudonyms are stable for a given salt, so diff --git a/plugins/adoption-analytics/src/components/AdoptionAnalyticsPage.tsx b/plugins/adoption-analytics/src/components/AdoptionAnalyticsPage.tsx index 238cfdd..5640522 100644 --- a/plugins/adoption-analytics/src/components/AdoptionAnalyticsPage.tsx +++ b/plugins/adoption-analytics/src/components/AdoptionAnalyticsPage.tsx @@ -14,9 +14,11 @@ import { ActiveUsersList } from './ActiveUsersList'; import { DauChart } from './DauChart'; import { EntityGrowthChart } from './EntityGrowthChart'; import { KpiCards } from './KpiCards'; +import { PluginAdoptionTable } from './PluginAdoptionTable'; import { SearchVolumeChart } from './SearchVolumeChart'; import { SectionTabs, type SectionTabDef } from './SectionTabs'; import { TopBar } from './TopBar'; +import { TopDocsTable } from './TopDocsTable'; import { TopEntitiesTable } from './TopEntitiesTable'; import { TopPagesTable } from './TopPagesTable'; import { TopSearchTerms } from './TopSearchTerms'; @@ -24,9 +26,14 @@ import { WauSessionsChart } from './WauSessionsChart'; import { analyticsColors, uiFont } from './tokens'; import { useAdoptionAnalyticsFonts } from './useAdoptionAnalyticsFonts'; -type SectionId = 'catalog' | 'search' | 'users'; +type SectionId = 'catalog' | 'search' | 'users' | 'plugins'; const DEFAULT_SECTION: SectionId = 'users'; -const SECTION_IDS: readonly SectionId[] = ['users', 'catalog', 'search']; +const SECTION_IDS: readonly SectionId[] = [ + 'users', + 'catalog', + 'search', + 'plugins', +]; function isSectionId(v: string | null): v is SectionId { return v !== null && (SECTION_IDS as readonly string[]).includes(v); @@ -212,6 +219,7 @@ function renderBody({ { id: 'users', label: 'Users', count: data.activeUsers.length }, { id: 'catalog', label: 'Catalog', count: data.topEntities.length }, { id: 'search', label: 'Search', count: data.search.total }, + { id: 'plugins', label: 'Plugins', count: data.plugins.length }, ]; return ( @@ -227,6 +235,9 @@ function renderBody({ {activeTab === 'search' ? ( ) : null} + {activeTab === 'plugins' ? ( + + ) : null} ); } @@ -253,6 +264,7 @@ function CatalogSection({ data }: SectionProps) { <> + ); diff --git a/plugins/adoption-analytics/src/components/DauChart.tsx b/plugins/adoption-analytics/src/components/DauChart.tsx index 3345511..8edbb1e 100644 --- a/plugins/adoption-analytics/src/components/DauChart.tsx +++ b/plugins/adoption-analytics/src/components/DauChart.tsx @@ -3,6 +3,7 @@ import { Area, AreaChart, CartesianGrid, + Legend, ResponsiveContainer, Tooltip, XAxis, @@ -10,6 +11,7 @@ import { } from 'recharts'; import { ChartCard, chartPalette, useAxisStyle } from './ChartCard'; import { ChartTooltip } from './ChartTooltip'; +import { monoFont } from './tokens'; type Props = { data: ActiveUsersSummary; @@ -17,10 +19,11 @@ type Props = { export function DauChart({ data }: Props) { const axisStyle = useAxisStyle(); + const newTotal = data.points.reduce((sum, p) => sum + p.newUsers, 0); return ( + + + + } /> + + {/* Stacked so the two bands still add up to total DAU — the + split answers "is growth new sign-ups or repeat usage?" + without losing the headline number. */} + diff --git a/plugins/adoption-analytics/src/components/PluginAdoptionTable.tsx b/plugins/adoption-analytics/src/components/PluginAdoptionTable.tsx new file mode 100644 index 0000000..014aea5 --- /dev/null +++ b/plugins/adoption-analytics/src/components/PluginAdoptionTable.tsx @@ -0,0 +1,228 @@ +import { makeStyles } from '@material-ui/core/styles'; +import type { PluginAdoptionStat } from '@codeverse-gp/plugin-adoption-analytics-common'; +import { TrendingDown, TrendingUp } from 'lucide-react'; +import { ChartCard } from './ChartCard'; +import { + BODY_HEIGHT_PX, + HEADER_HEIGHT_PX, + PaginationFooter, + ROW_HEIGHT_PX, + usePagedList, +} from './pagination'; +import { analyticsColors, monoFont, uiFont } from './tokens'; + +// Fixed widths on the metric columns: `auto` sized them to their content, +// which let the numbers drift away from the headings above them. +const COLUMNS = '32px minmax(0, 1fr) 80px 80px 132px 88px'; + +const useStyles = makeStyles(theme => { + const colors = analyticsColors(theme); + return { + body: { + display: 'flex', + flexDirection: 'column', + minHeight: BODY_HEIGHT_PX, + }, + header: { + display: 'grid', + gridTemplateColumns: COLUMNS, + gap: theme.spacing(3), + padding: theme.spacing(1, 1.5), + fontFamily: uiFont, + fontSize: 11, + letterSpacing: 0.5, + textTransform: 'uppercase', + color: colors.muted, + borderBottom: `1px solid ${colors.border}`, + height: HEADER_HEIGHT_PX, + boxSizing: 'border-box', + }, + headEnd: { + textAlign: 'right', + }, + rows: { + flex: '1 1 auto', + }, + row: { + display: 'grid', + gridTemplateColumns: COLUMNS, + gap: theme.spacing(3), + alignItems: 'center', + padding: theme.spacing(1.25, 1.5), + borderBottom: `1px solid ${colors.border}`, + fontFamily: monoFont, + fontSize: 12, + color: colors.text, + height: ROW_HEIGHT_PX, + boxSizing: 'border-box', + transition: 'background 120ms ease', + '&:hover': { + background: colors.hover, + }, + '&:last-child': { + borderBottom: 'none', + }, + }, + rank: { + color: colors.muted, + fontVariantNumeric: 'tabular-nums', + }, + plugin: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + minWidth: 0, + }, + metric: { + fontVariantNumeric: 'tabular-nums', + textAlign: 'right', + }, + share: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: theme.spacing(1), + }, + shareTrack: { + flex: '1 1 auto', + height: 4, + borderRadius: 2, + background: colors.neutralSurface, + overflow: 'hidden', + }, + shareFill: { + display: 'block', + height: '100%', + background: colors.primary, + }, + shareValue: { + color: colors.muted, + fontVariantNumeric: 'tabular-nums', + minWidth: 32, + textAlign: 'right', + }, + trend: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 4, + fontWeight: 500, + fontVariantNumeric: 'tabular-nums', + minWidth: 64, + }, + trendPositive: { color: colors.positive }, + trendNegative: { color: colors.negative }, + trendNeutral: { color: colors.muted }, + empty: { + fontFamily: monoFont, + fontSize: 12, + color: colors.muted, + padding: theme.spacing(3, 0), + textAlign: 'center', + flex: '1 1 auto', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + maxWidth: 420, + margin: '0 auto', + }, + }; +}); + +type Props = { + plugins: PluginAdoptionStat[]; +}; + +export function PluginAdoptionTable({ plugins }: Props) { + const classes = useStyles(); + const paged = usePagedList(plugins); + const totalEvents = plugins.reduce((sum, p) => sum + p.events, 0); + const subtitle = + plugins.length === 0 + ? 'no plugin activity' + : `${plugins.length} plugin${ + plugins.length === 1 ? '' : 's' + } · ${totalEvents.toLocaleString()} events`; + + return ( + +
+ {plugins.length === 0 ? ( +
+ No plugin-attributed events in this window. Only events carrying an + analytics plugin id are counted here. +
+ ) : ( + <> +
+ # + Plugin + Events + Users + Share + Trend +
+
+ {paged.visible.map((row, i) => { + const trend = row.trendPct; + const TrendIcon = + trend === null || trend >= 0 ? TrendingUp : TrendingDown; + const share = + totalEvents === 0 ? 0 : (row.events / totalEvents) * 100; + return ( +
+ {paged.start + i + 1} + + {row.pluginId} + + + {row.events.toLocaleString()} + + + {row.users.toLocaleString()} + + + + + + + {share.toFixed(0)}% + + + + {trend === null ? ( + '—' + ) : ( + <> + + {`${trend >= 0 ? '+' : ''}${trend.toFixed(1)}%`} + + )} + +
+ ); + })} +
+ + + )} +
+
+ ); +} + +function trendClassFor( + trend: number | null, + classes: ReturnType, +): string { + if (trend === null) return classes.trendNeutral; + return trend >= 0 ? classes.trendPositive : classes.trendNegative; +} diff --git a/plugins/adoption-analytics/src/components/SectionTabs.tsx b/plugins/adoption-analytics/src/components/SectionTabs.tsx index b0da883..25e5bf1 100644 --- a/plugins/adoption-analytics/src/components/SectionTabs.tsx +++ b/plugins/adoption-analytics/src/components/SectionTabs.tsx @@ -55,7 +55,7 @@ type Props = { /** * Underline-style tab bar used to split the Adoption Analytics dashboard into - * themed sections (Users / Catalog / Search). Visually distinct from + * themed sections (Users / Catalog / Search / Plugins). Visually distinct from * the pill-style range selector in `TopBar` so the two controls don't * get confused for the same thing. */ diff --git a/plugins/adoption-analytics/src/components/TopDocsTable.tsx b/plugins/adoption-analytics/src/components/TopDocsTable.tsx new file mode 100644 index 0000000..e7183e5 --- /dev/null +++ b/plugins/adoption-analytics/src/components/TopDocsTable.tsx @@ -0,0 +1,167 @@ +import { makeStyles } from '@material-ui/core/styles'; +import type { TechDocsSiteStat } from '@codeverse-gp/plugin-adoption-analytics-common'; +import { BookOpen, TrendingDown, TrendingUp } from 'lucide-react'; +import { ChartCard } from './ChartCard'; +import { analyticsColors, monoFont, uiFont } from './tokens'; + +const useStyles = makeStyles(theme => { + const colors = analyticsColors(theme); + return { + table: { + width: '100%', + borderCollapse: 'collapse', + fontFamily: monoFont, + fontSize: 12, + }, + head: { + fontFamily: uiFont, + fontSize: 11, + letterSpacing: 0.5, + textTransform: 'uppercase', + color: colors.muted, + textAlign: 'left', + padding: theme.spacing(1, 1.5), + borderBottom: `1px solid ${colors.border}`, + }, + cell: { + padding: theme.spacing(1.25, 1.5), + borderBottom: `1px solid ${colors.border}`, + color: colors.text, + }, + rowStripe: { + background: colors.tableStripe, + }, + row: { + transition: 'background 120ms ease', + '&:hover td': { + background: colors.hover, + }, + }, + site: { + display: 'inline-flex', + alignItems: 'center', + gap: 8, + minWidth: 0, + }, + siteName: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + icon: { + color: colors.muted, + display: 'inline-flex', + flexShrink: 0, + }, + numeric: { + textAlign: 'right', + fontVariantNumeric: 'tabular-nums', + }, + trend: { + display: 'inline-flex', + alignItems: 'center', + gap: 4, + fontWeight: 500, + }, + trendPositive: { color: colors.positive }, + trendNegative: { color: colors.negative }, + trendNeutral: { color: colors.muted }, + empty: { + fontSize: 12, + color: colors.muted, + padding: theme.spacing(3, 0), + textAlign: 'center', + }, + }; +}); + +type Props = { + docs: TechDocsSiteStat[]; +}; + +export function TopDocsTable({ docs }: Props) { + const classes = useStyles(); + + if (docs.length === 0) { + return ( + +
+ No documentation pages were opened in this window. +
+
+ ); + } + + return ( + + + + + + + + + + + + + + {docs.map((row, i) => { + const trend = row.trendPct; + const TrendIcon = + trend === null || trend >= 0 ? TrendingUp : TrendingDown; + const rowClass = + i % 2 === 1 ? `${classes.row} ${classes.rowStripe}` : classes.row; + return ( + + + + + + + + + ); + })} + +
SiteOwnerViewsReadersPagesTrend
+ + + + + {row.name} + + {row.owner ?? '—'} + {row.views.toLocaleString()} + + {row.readers.toLocaleString()} + + {row.pages.toLocaleString()} + + + {trend === null ? ( + '—' + ) : ( + <> + + {`${trend >= 0 ? '+' : ''}${trend.toFixed(1)}%`} + + )} + +
+
+ ); +} + +function trendClassFor( + trend: number | null, + classes: ReturnType, +): string { + if (trend === null) return classes.trendNeutral; + return trend >= 0 ? classes.trendPositive : classes.trendNegative; +} From 033030496f1ba66b2b294770b009ba67365a2c12 Mon Sep 17 00:00:00 2001 From: Pooja Kandpal <150530053+kandpalpooja@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:26:26 +0200 Subject: [PATCH 2/2] chore: bump package version --- plugins/adoption-analytics-backend/package.json | 2 +- plugins/adoption-analytics-common/package.json | 2 +- plugins/adoption-analytics/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/adoption-analytics-backend/package.json b/plugins/adoption-analytics-backend/package.json index 1e1dcdf..19a6fd8 100644 --- a/plugins/adoption-analytics-backend/package.json +++ b/plugins/adoption-analytics-backend/package.json @@ -1,6 +1,6 @@ { "name": "@codeverse-gp/plugin-adoption-analytics-backend", - "version": "1.0.0", + "version": "1.1.0", "description": "Backstage backend plugin that ingests and aggregates portal usage analytics (entity counts, DAU/WAU, sessions, logins).", "license": "MIT", "author": "CodeVerse-GP", diff --git a/plugins/adoption-analytics-common/package.json b/plugins/adoption-analytics-common/package.json index ca61cf6..8e37a46 100644 --- a/plugins/adoption-analytics-common/package.json +++ b/plugins/adoption-analytics-common/package.json @@ -1,6 +1,6 @@ { "name": "@codeverse-gp/plugin-adoption-analytics-common", - "version": "1.0.0", + "version": "1.1.0", "description": "Shared types and permission definitions for the Backstage adoption analytics plugins.", "license": "MIT", "author": "CodeVerse-GP", diff --git a/plugins/adoption-analytics/package.json b/plugins/adoption-analytics/package.json index 37ce5e4..137d032 100644 --- a/plugins/adoption-analytics/package.json +++ b/plugins/adoption-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@codeverse-gp/plugin-adoption-analytics", - "version": "1.0.0", + "version": "1.1.0", "description": "Backstage frontend plugin that captures portal usage analytics and renders an adoption dashboard.", "license": "MIT", "author": "CodeVerse-GP",