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:
parent
0e26f463e9
commit
a547e51bc9
@ -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}
|
||||
|
||||
|
||||
173
frontend/src/component/impact-metrics/PlausibleChart.tsx
Normal file
173
frontend/src/component/impact-metrics/PlausibleChart.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
69
frontend/src/component/impact-metrics/PlausibleChartItem.tsx
Normal file
69
frontend/src/component/impact-metrics/PlausibleChartItem.tsx
Normal 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>
|
||||
);
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -85,6 +85,7 @@ export type UiFlags = {
|
||||
edgeObservability?: boolean;
|
||||
customMetrics?: boolean;
|
||||
impactMetrics?: boolean;
|
||||
plausibleMetrics?: boolean;
|
||||
lifecycleGraphs?: boolean;
|
||||
newStrategyModal?: boolean;
|
||||
globalChangeRequestList?: boolean;
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user