From a547e51bc9ce2f1549768c4ff14d09269145981b Mon Sep 17 00:00:00 2001 From: Jaanus Date: Fri, 24 Oct 2025 11:59:17 +0300 Subject: [PATCH] feat: plausible metrics chart --- .../impact-metrics/ImpactMetrics.tsx | 84 ++++++--- .../impact-metrics/PlausibleChart.tsx | 173 ++++++++++++++++++ .../impact-metrics/PlausibleChartItem.tsx | 69 +++++++ .../usePlausibleMetrics.ts | 23 +++ frontend/src/interfaces/uiConfig.ts | 1 + src/lib/db/client-applications-store.ts | 28 +-- 6 files changed, 325 insertions(+), 53 deletions(-) create mode 100644 frontend/src/component/impact-metrics/PlausibleChart.tsx create mode 100644 frontend/src/component/impact-metrics/PlausibleChartItem.tsx create mode 100644 frontend/src/hooks/api/getters/usePlausibleMetrics/usePlausibleMetrics.ts diff --git a/frontend/src/component/impact-metrics/ImpactMetrics.tsx b/frontend/src/component/impact-metrics/ImpactMetrics.tsx index 6eea82453c..14e31ac445 100644 --- a/frontend/src/component/impact-metrics/ImpactMetrics.tsx +++ b/frontend/src/component/impact-metrics/ImpactMetrics.tsx @@ -6,6 +6,7 @@ import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx'; import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; import { ChartConfigModal } from './ChartConfigModal/ChartConfigModal.tsx'; import { ChartItem } from './ChartItem.tsx'; +import { PlausibleChartItem } from './PlausibleChartItem.tsx'; import { GridLayoutWrapper, type GridItem } from './GridLayoutWrapper.tsx'; import { useImpactMetricsState } from './hooks/useImpactMetricsState.ts'; import type { ChartConfig } from './types.ts'; @@ -13,6 +14,7 @@ import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; import PermissionButton from 'component/common/PermissionButton/PermissionButton.tsx'; import { ADMIN } from '../providers/AccessProvider/permissions.ts'; +import { useUiFlag } from 'hooks/useUiFlag'; const StyledEmptyState = styled(Paper)(({ theme }) => ({ textAlign: 'center', @@ -39,6 +41,7 @@ export const ImpactMetrics: FC = () => { const [modalOpen, setModalOpen] = useState(false); const [editingChart, setEditingChart] = useState(); const { setToastApiError } = useToast(); + const plausibleMetricsEnabled = useUiFlag('plausibleMetrics'); const { charts, @@ -100,33 +103,56 @@ export const ImpactMetrics: FC = () => { [deleteChart], ); - const gridItems: GridItem[] = useMemo( - () => - charts.map((config, index) => { - const existingLayout = layout?.find( - (item) => item.i === config.id, - ); - return { - id: config.id, - component: ( - - ), - w: existingLayout?.w ?? 6, - h: existingLayout?.h ?? 4, - x: existingLayout?.x, - y: existingLayout?.y, - minW: 4, - minH: 2, - maxW: 12, - maxH: 8, - }; - }), - [charts, layout, handleEditChart, handleDeleteChart], - ); + const gridItems: GridItem[] = useMemo(() => { + const items: GridItem[] = []; + + if (plausibleMetricsEnabled) { + const plausibleChartItem: GridItem = { + id: 'plausible-analytics', + component: , + w: 12, + h: 4, + x: 0, + y: 0, + minW: 6, + minH: 3, + maxW: 12, + maxH: 6, + }; + items.push(plausibleChartItem); + } + + const impactMetricsItems: GridItem[] = charts.map((config, index) => { + const existingLayout = layout?.find((item) => item.i === config.id); + const yOffset = plausibleMetricsEnabled ? 4 : 0; + return { + id: config.id, + component: ( + + ), + w: existingLayout?.w ?? 6, + h: existingLayout?.h ?? 4, + x: existingLayout?.x, + y: existingLayout?.y ? existingLayout.y + yOffset : yOffset, + minW: 4, + minH: 2, + maxW: 12, + maxH: 8, + }; + }); + + return [...items, ...impactMetricsItems]; + }, [ + charts, + layout, + handleEditChart, + handleDeleteChart, + plausibleMetricsEnabled, + ]); const hasError = metadataError || settingsError; const isLoading = metadataLoading || settingsLoading; @@ -156,7 +182,7 @@ export const ImpactMetrics: FC = () => { {charts.length === 0 && !isLoading && !hasError ? ( - No charts configured + No impact metrics charts configured { Add Chart - ) : charts.length > 0 ? ( + ) : gridItems.length > 0 ? ( ) : null} diff --git a/frontend/src/component/impact-metrics/PlausibleChart.tsx b/frontend/src/component/impact-metrics/PlausibleChart.tsx new file mode 100644 index 0000000000..8de6b33ab8 --- /dev/null +++ b/frontend/src/component/impact-metrics/PlausibleChart.tsx @@ -0,0 +1,173 @@ +import type { FC, ReactNode } from 'react'; +import { useMemo } from 'react'; +import { Alert, Box } from '@mui/material'; +import { + LineChart, + NotEnoughData, +} from '../insights/components/LineChart/LineChart.tsx'; +import { usePlausibleMetrics } from 'hooks/api/getters/usePlausibleMetrics/usePlausibleMetrics'; +import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js'; +import { formatLargeNumbers } from './metricsFormatters.js'; +import type { ChartData } from 'chart.js'; + +type PlausibleChartProps = { + aspectRatio?: number; + overrideOptions?: Record; + errorTitle?: string; + emptyDataDescription?: string; + noSeriesPlaceholder?: ReactNode; + isPreview?: boolean; +}; + +export const PlausibleChart: FC = ({ + aspectRatio, + overrideOptions = {}, + errorTitle = 'Failed to load Plausible metrics.', + emptyDataDescription = 'No Plausible analytics data available.', + noSeriesPlaceholder, + isPreview, +}) => { + const { + data: plausibleData, + loading: dataLoading, + error: dataError, + } = usePlausibleMetrics(); + + const placeholderData = usePlaceholderData({ + fill: true, + type: 'constant', + }); + + const chartData: ChartData<'line'> = useMemo(() => { + if (!plausibleData?.data || plausibleData.data.length === 0) { + return { + labels: [], + datasets: [], + }; + } + + const sortedData = [...plausibleData.data].sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ); + + return { + labels: sortedData.map((item) => item.date), + datasets: [ + { + label: 'Events', + data: sortedData.map((item) => item.count), + borderColor: 'rgb(129, 122, 254)', + backgroundColor: 'rgba(129, 122, 254, 0.1)', + fill: true, + tension: 0.1, + }, + ], + }; + }, [plausibleData]); + + const hasError = !!dataError; + const isLoading = dataLoading; + const notEnoughData = useMemo( + () => + !isLoading && + (!plausibleData?.data || + plausibleData.data.length === 0 || + !chartData.datasets.some((d) => d.data.length > 1)), + [chartData, isLoading, plausibleData], + ); + + const placeholder = noSeriesPlaceholder ? ( + noSeriesPlaceholder + ) : ( + + ); + + const cover = notEnoughData ? placeholder : isLoading; + + const chartOptions = { + ...overrideOptions, + scales: { + x: { + type: 'time', + time: { + unit: 'hour', + displayFormats: { + hour: 'MMM dd, HH:mm', + }, + tooltipFormat: 'PPpp', + }, + ticks: { + maxRotation: 45, + minRotation: 45, + maxTicksLimit: 8, + }, + }, + y: { + beginAtZero: true, + title: { + display: true, + text: 'Events', + }, + ticks: { + precision: 0, + callback: (value: unknown): string | number => + typeof value === 'number' + ? formatLargeNumbers(value) + : (value as number), + }, + }, + }, + plugins: { + legend: { + display: true, + position: 'bottom' as const, + labels: { + usePointStyle: true, + boxWidth: 8, + padding: 12, + }, + }, + }, + animations: { + x: { duration: 0 }, + y: { duration: 0 }, + }, + }; + + return ( + <> + div': { + height: '100% !important', + width: '100% !important', + }, + } + : {} + } + > + {errorTitle} + ) : ( + cover + ) + } + /> + + + ); +}; diff --git a/frontend/src/component/impact-metrics/PlausibleChartItem.tsx b/frontend/src/component/impact-metrics/PlausibleChartItem.tsx new file mode 100644 index 0000000000..1c1ffa5325 --- /dev/null +++ b/frontend/src/component/impact-metrics/PlausibleChartItem.tsx @@ -0,0 +1,69 @@ +import type { FC } from 'react'; +import { Box, Typography, styled, Paper } from '@mui/material'; +import { PlausibleChart } from './PlausibleChart.tsx'; + +const StyledWidget = styled(Paper)(({ theme }) => ({ + borderRadius: `${theme.shape.borderRadiusMedium}px`, + boxShadow: 'none', + display: 'flex', + flexDirection: 'column', + height: '100%', +})); + +const StyledChartContent = styled(Box)({ + flex: 1, + display: 'flex', + flexDirection: 'column', + minHeight: 0, +}); + +const StyledImpactChartContainer = styled(Box)(({ theme }) => ({ + position: 'relative', + minWidth: 0, + flexGrow: 1, + height: '100%', + display: 'flex', + flexDirection: 'column', + margin: 'auto 0', + padding: theme.spacing(3), +})); + +const StyledHeader = styled(Box)(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(2), + alignItems: 'center', + padding: theme.spacing(1.5, 2), + borderBottom: `1px solid ${theme.palette.divider}`, +})); + +const StyledChartTitle = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-end', + flexGrow: 1, + overflow: 'hidden', + textOverflow: 'ellipsis', +})); + +export const PlausibleChartItem: FC = () => ( + + + + Plausible Analytics + + Favorite events from Plausible analytics + + + + + + + + + + +); diff --git a/frontend/src/hooks/api/getters/usePlausibleMetrics/usePlausibleMetrics.ts b/frontend/src/hooks/api/getters/usePlausibleMetrics/usePlausibleMetrics.ts new file mode 100644 index 0000000000..f50ddf05ba --- /dev/null +++ b/frontend/src/hooks/api/getters/usePlausibleMetrics/usePlausibleMetrics.ts @@ -0,0 +1,23 @@ +import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter.js'; +import { formatApiPath } from 'utils/formatPath'; +import type { PlausibleMetricsResponseSchema } from 'openapi/models/plausibleMetricsResponseSchema.js'; + +export const usePlausibleMetrics = () => { + const PATH = 'api/admin/impact-metrics/plausible'; + const { data, refetch, loading, error } = + useApiGetter( + formatApiPath(PATH), + () => fetcher(formatApiPath(PATH), 'Plausible metrics'), + { + refreshInterval: 30 * 1_000, + revalidateOnFocus: true, + }, + ); + + return { + data: data || { data: [] }, + refetch, + loading, + error, + }; +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 1c84ae5289..9c0ceef38a 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -85,6 +85,7 @@ export type UiFlags = { edgeObservability?: boolean; customMetrics?: boolean; impactMetrics?: boolean; + plausibleMetrics?: boolean; lifecycleGraphs?: boolean; newStrategyModal?: boolean; globalChangeRequestList?: boolean; diff --git a/src/lib/db/client-applications-store.ts b/src/lib/db/client-applications-store.ts index bc9e9e005c..ad3894d364 100644 --- a/src/lib/db/client-applications-store.ts +++ b/src/lib/db/client-applications-store.ts @@ -141,7 +141,6 @@ export default class ClientApplicationsStore } async upsert(details: Partial): Promise { - const stopTimer = this.timer('upsert'); const row = remapRow(details); await this.db(TABLE).insert(row).onConflict('app_name').merge(); const usageRows = this.remapUsageRow(details); @@ -149,11 +148,9 @@ export default class ClientApplicationsStore .insert(usageRows) .onConflict(['app_name', 'project', 'environment']) .merge(); - stopTimer(); } async bulkUpsert(apps: Partial[]): Promise { - const stopTimer = this.timer('bulkUpsert'); const rows = apps.map(remapRow); const uniqueRows = Object.values( rows.reduce((acc, row) => { @@ -179,38 +176,33 @@ export default class ClientApplicationsStore .insert(uniqueUsageRows) .onConflict(['app_name', 'project', 'environment']) .merge(); - stopTimer(); } async exists(appName: string): Promise { - const stopTimer = this.timer('exists'); const result = await this.db.raw( `SELECT EXISTS(SELECT 1 FROM ${TABLE} WHERE app_name = ?) AS present`, [appName], ); const { present } = result.rows[0]; - stopTimer(); return present; } async getAll(): Promise { - const stopTimer = this.timer('getAll'); const rows = await this.db .select(COLUMNS) .from(TABLE) .orderBy('app_name', 'asc'); - stopTimer(); + return rows.map(mapRow); } async getApplication(appName: string): Promise { - const stopTimer = this.timer('getApplication'); const row = await this.db .select(COLUMNS) .where('app_name', appName) .from(TABLE) .first(); - stopTimer(); + if (!row) { throw new NotFoundError(`Could not find appName=${appName}`); } @@ -225,7 +217,6 @@ export default class ClientApplicationsStore async getApplications( params: IClientApplicationsSearchParams, ): Promise { - const stopTimer = this.timer('getApplications'); const { limit, offset, sortOrder = 'asc', searchParams } = params; const validatedSortOrder = sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; @@ -266,7 +257,6 @@ export default class ClientApplicationsStore .whereBetween('rank', [offset + 1, offset + limit]); const rows = await query; - stopTimer(); if (rows.length !== 0) { const applications = reduceRows(rows); @@ -283,11 +273,9 @@ export default class ClientApplicationsStore } async getUnannounced(): Promise { - const stopTimer = this.timer('getUnannounced'); const rows = await this.db(TABLE) .select(COLUMNS) .where('announced', false); - stopTimer(); return rows.map(mapRow); } @@ -296,38 +284,31 @@ export default class ClientApplicationsStore * @return {[app]} - Apps that hadn't been announced */ async setUnannouncedToAnnounced(): Promise { - const stopTimer = this.timer('setUnannouncedToAnnounced'); const rows = await this.db(TABLE) .update({ announced: true }) .where('announced', false) .whereNotNull('announced') .returning(COLUMNS); - stopTimer(); return rows.map(mapRow); } async delete(key: string): Promise { - const stopTimer = this.timer('delete'); await this.db(TABLE).where('app_name', key).del(); - stopTimer(); } async deleteAll(): Promise { - const stopTimer = this.timer('deleteAll'); await this.db(TABLE).del(); - stopTimer(); } destroy(): void {} async get(appName: string): Promise { - const stopTimer = this.timer('get'); const row = await this.db .select(COLUMNS) .where('app_name', appName) .from(TABLE) .first(); - stopTimer(); + if (!row) { throw new NotFoundError(`Could not find appName=${appName}`); } @@ -500,11 +481,10 @@ export default class ClientApplicationsStore }; async removeInactiveApplications(): Promise { - const stopTimer = this.timer('removeInactiveApplications'); const rows = await this.db(TABLE) .whereRaw("seen_at < now() - interval '30 days'") .del(); - stopTimer(); + if (rows > 0) { this.logger.debug(`Deleted ${rows} applications`); }