1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00

feat: plausible metrics chart

This commit is contained in:
Jaanus 2025-10-24 11:59:17 +03:00
parent 0e26f463e9
commit a547e51bc9
No known key found for this signature in database
6 changed files with 325 additions and 53 deletions

View File

@ -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<ChartConfig | undefined>();
const { setToastApiError } = useToast();
const plausibleMetricsEnabled = useUiFlag('plausibleMetrics');
const {
charts,
@ -100,12 +103,28 @@ export const ImpactMetrics: FC = () => {
[deleteChart],
);
const gridItems: GridItem[] = useMemo(
() =>
charts.map((config, index) => {
const existingLayout = layout?.find(
(item) => item.i === config.id,
);
const gridItems: GridItem[] = useMemo(() => {
const items: GridItem[] = [];
if (plausibleMetricsEnabled) {
const plausibleChartItem: GridItem = {
id: 'plausible-analytics',
component: <PlausibleChartItem />,
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: (
@ -118,15 +137,22 @@ export const ImpactMetrics: FC = () => {
w: existingLayout?.w ?? 6,
h: existingLayout?.h ?? 4,
x: existingLayout?.x,
y: existingLayout?.y,
y: existingLayout?.y ? existingLayout.y + yOffset : yOffset,
minW: 4,
minH: 2,
maxW: 12,
maxH: 8,
};
}),
[charts, layout, handleEditChart, handleDeleteChart],
);
});
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 ? (
<StyledEmptyState>
<Typography variant='h6' gutterBottom>
No charts configured
No impact metrics charts configured
</Typography>
<Typography
variant='body2'
@ -175,7 +201,7 @@ export const ImpactMetrics: FC = () => {
Add Chart
</Button>
</StyledEmptyState>
) : charts.length > 0 ? (
) : gridItems.length > 0 ? (
<GridLayoutWrapper items={gridItems} />
) : null}

View File

@ -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<string, unknown>;
errorTitle?: string;
emptyDataDescription?: string;
noSeriesPlaceholder?: ReactNode;
isPreview?: boolean;
};
export const PlausibleChart: FC<PlausibleChartProps> = ({
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
) : (
<NotEnoughData
title='No Plausible data available'
description={emptyDataDescription}
/>
);
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 (
<>
<Box
sx={
!isPreview
? {
height: '100%',
width: '100%',
'& > div': {
height: '100% !important',
width: '100% !important',
},
}
: {}
}
>
<LineChart
data={
notEnoughData || isLoading ? placeholderData : chartData
}
aspectRatio={aspectRatio}
overrideOptions={chartOptions}
cover={
hasError ? (
<Alert severity='error'>{errorTitle}</Alert>
) : (
cover
)
}
/>
</Box>
</>
);
};

View File

@ -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 = () => (
<StyledWidget>
<StyledHeader>
<StyledChartTitle>
<Typography variant='h6'>Plausible Analytics</Typography>
<Typography variant='body2' color='text.secondary'>
Favorite events from Plausible analytics
</Typography>
</StyledChartTitle>
</StyledHeader>
<StyledChartContent>
<StyledImpactChartContainer>
<PlausibleChart
aspectRatio={1.5}
overrideOptions={{ maintainAspectRatio: false }}
emptyDataDescription='No Plausible analytics data available for favorite events.'
/>
</StyledImpactChartContainer>
</StyledChartContent>
</StyledWidget>
);

View File

@ -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<PlausibleMetricsResponseSchema>(
formatApiPath(PATH),
() => fetcher(formatApiPath(PATH), 'Plausible metrics'),
{
refreshInterval: 30 * 1_000,
revalidateOnFocus: true,
},
);
return {
data: data || { data: [] },
refetch,
loading,
error,
};
};

View File

@ -85,6 +85,7 @@ export type UiFlags = {
edgeObservability?: boolean;
customMetrics?: boolean;
impactMetrics?: boolean;
plausibleMetrics?: boolean;
lifecycleGraphs?: boolean;
newStrategyModal?: boolean;
globalChangeRequestList?: boolean;

View File

@ -141,7 +141,6 @@ export default class ClientApplicationsStore
}
async upsert(details: Partial<IClientApplication>): Promise<void> {
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<IClientApplication>[]): Promise<void> {
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<boolean> {
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<IClientApplication[]> {
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<IClientApplication> {
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<IClientApplications> {
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<IClientApplication[]> {
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<IClientApplication[]> {
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<void> {
const stopTimer = this.timer('delete');
await this.db(TABLE).where('app_name', key).del();
stopTimer();
}
async deleteAll(): Promise<void> {
const stopTimer = this.timer('deleteAll');
await this.db(TABLE).del();
stopTimer();
}
destroy(): void {}
async get(appName: string): Promise<IClientApplication> {
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<number> {
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`);
}