diff --git a/packages/pl-api/lib/client.ts b/packages/pl-api/lib/client.ts index 7140baf8e..22560a475 100644 --- a/packages/pl-api/lib/client.ts +++ b/packages/pl-api/lib/client.ts @@ -4237,7 +4237,7 @@ class PlApiClient { * @see {@link https://docs.joinmastodon.org/methods/admin/dimensions/#get} */ getDimensions: async (keys: AdminDimensionKey[], params?: AdminGetDimensionsParams) => { - const response = await this.request('/api/v1/admin/dimensions', { params: { ...params, keys } }); + const response = await this.request('/api/v1/admin/dimensions', { method: 'POST', params: { ...params, keys } }); return v.parse(filteredArray(adminDimensionSchema), response.json); }, @@ -4394,7 +4394,7 @@ class PlApiClient { * @see {@link https://docs.joinmastodon.org/methods/admin/measures/#get} */ getMeasures: async (keys: AdminMeasureKey[], start_at: string, end_at: string, params?: AdminGetMeasuresParams) => { - const response = await this.request('/api/v1/admin/measures', { params: { ...params, keys, start_at, end_at } }); + const response = await this.request('/api/v1/admin/measures', { method: 'POST', params: { ...params, keys, start_at, end_at } }); return v.parse(filteredArray(adminMeasureSchema), response.json); }, @@ -4409,7 +4409,7 @@ class PlApiClient { * @see {@link https://docs.joinmastodon.org/methods/admin/retention/#create} */ getRetention: async (start_at: string, end_at: string, frequency: 'day' | 'month') => { - const response = await this.request('/api/v1/admin/retention', { params: { start_at, end_at, frequency } }); + const response = await this.request('/api/v1/admin/retention', { method: 'POST', params: { start_at, end_at, frequency } }); return v.parse(adminCohortSchema, response.json); }, diff --git a/packages/pl-api/lib/entities/admin/measure.ts b/packages/pl-api/lib/entities/admin/measure.ts index f8b1d85ca..95d69fb2f 100644 --- a/packages/pl-api/lib/entities/admin/measure.ts +++ b/packages/pl-api/lib/entities/admin/measure.ts @@ -1,6 +1,6 @@ import * as v from 'valibot'; -import { datetimeSchema } from '../utils'; +import { dateSchema } from '../utils'; /** * @category Admin schemas @@ -11,10 +11,10 @@ const adminMeasureSchema = v.object({ unit: v.fallback(v.nullable(v.string()), null), total: v.pipe(v.unknown(), v.transform(Number)), human_value: v.fallback(v.optional(v.string()), undefined), - previous_total: v.fallback(v.optional(v.pipe(v.unknown(), v.transform(String))), undefined), + previous_total: v.fallback(v.optional(v.pipe(v.unknown(), v.transform(Number))), undefined), data: v.array(v.object({ - date: datetimeSchema, - value: v.pipe(v.unknown(), v.transform(String)), + date: dateSchema, + value: v.pipe(v.unknown(), v.transform(Number)), })), }); diff --git a/packages/pl-api/lib/entities/utils.ts b/packages/pl-api/lib/entities/utils.ts index e190f86c2..78250391a 100644 --- a/packages/pl-api/lib/entities/utils.ts +++ b/packages/pl-api/lib/entities/utils.ts @@ -9,6 +9,8 @@ const datetimeSchema = v.pipe( v.regex(/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?(([+-]\d{2}:?\d{2})|(Z)?)$/), ); +const dateSchema = v.pipe(v.string(), v.transform((value) => value.slice(0, 10)), v.regex(/^\d{4}-\d{2}-\d{2}$/)); + /** Validates individual items in an array, dropping any that aren't valid. */ const filteredArray = (schema: v.BaseSchema>) => v.pipe( @@ -35,4 +37,4 @@ const coerceObject = (shape: T) => v.object(shape), ); -export { filteredArray, emojiSchema, datetimeSchema, mimeSchema, coerceObject }; +export { filteredArray, emojiSchema, datetimeSchema, dateSchema, mimeSchema, coerceObject }; diff --git a/packages/pl-api/lib/features.ts b/packages/pl-api/lib/features.ts index ff822d53a..8bbffd78f 100644 --- a/packages/pl-api/lib/features.ts +++ b/packages/pl-api/lib/features.ts @@ -1070,6 +1070,14 @@ const getFeatures = (instance: Instance) => { v.software === PLEROMA && v.build === PL, ]), + /** + * @see POST /api/v1/admin/dimensions + * @see POST /api/v1/admin/measures + * @see POST /api/v1/admin/retention + */ + mastodonAdminMetrics: v.software === MASTODON && gte(v.version, '3.5.0'), + + /** * Can perform moderation actions with account and reports. * @see {@link https://docs.joinmastodon.org/methods/admin/} diff --git a/packages/pl-fe/src/features/admin/components/counter.tsx b/packages/pl-fe/src/features/admin/components/counter.tsx new file mode 100644 index 000000000..b876a89e0 --- /dev/null +++ b/packages/pl-fe/src/features/admin/components/counter.tsx @@ -0,0 +1,105 @@ +import clsx from 'clsx'; +import React from 'react'; +import { FormattedNumber } from 'react-intl'; +import { Link } from 'react-router-dom'; +import { Sparklines, SparklinesCurve } from 'react-sparklines'; + +import Text from 'pl-fe/components/ui/text'; +import { useMeasures } from 'pl-fe/queries/admin/use-metrics'; + +import type { AdminGetMeasuresParams, AdminMeasureKey } from 'pl-api'; + +const percIncrease = (a: number, b: number) => { + let percent; + + if (b !== 0) { + if (a !== 0) { + percent = (b - a) / a; + } else { + percent = 1; + } + } else if (b === 0 && a === 0) { + percent = 0; + } else { + percent = -1; + } + + return percent; +}; + +interface ICounter { + measure: AdminMeasureKey; + startAt: string; + endAt: string; + label: JSX.Element | string; + to?: string; + params?: AdminGetMeasuresParams; + target?: string; +} + +const Counter: React.FC = ({ + measure, + startAt, + endAt, + label, + to, + params, + target, +}) => { + const { data } = useMeasures([measure], startAt, endAt, params); + + let content; + + if (!data) { + content = ( + <> + {/* + */} + + ); + } else { + const measure = data![0]; + const percentChange = measure.previous_total !== undefined && percIncrease(measure.previous_total * 1, measure.total * 1) || 0; + + content = ( + <> + {measure.human_value || } + {measure.previous_total !== undefined && ( 0, 'text-danger-600': percentChange < 0 })}>{percentChange > 0 && '+'})} + + ); + } + + const inner = ( + <> +
+ {content} +
+ + + {label} + + +
+ x.value * 1) || []}> + + +
+ + ); + + if (to) { + return ( + + {inner} + + ); + } else { + return ( +
+ {inner} +
+ ); + } +}; + +export { Counter }; diff --git a/packages/pl-fe/src/features/admin/tabs/dashboard.tsx b/packages/pl-fe/src/features/admin/tabs/dashboard.tsx index c05fbdd87..c76ff9a95 100644 --- a/packages/pl-fe/src/features/admin/tabs/dashboard.tsx +++ b/packages/pl-fe/src/features/admin/tabs/dashboard.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { FormattedMessage } from 'react-intl'; import List, { ListItem } from 'pl-fe/components/list'; @@ -10,6 +10,7 @@ import { useInstance } from 'pl-fe/hooks/use-instance'; import { useOwnAccount } from 'pl-fe/hooks/use-own-account'; import sourceCode from 'pl-fe/utils/code'; +import { Counter } from '../components/counter'; import { DashCounter, DashCounters } from '../components/dashcounter'; import RegistrationModePicker from '../components/registration-mode-picker'; @@ -29,25 +30,73 @@ const Dashboard: React.FC = () => { const mau = instance.usage.users.active_month ?? instance.pleroma.stats.mau; const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : undefined; + const [endDay] = useState(new Date().toISOString().slice(0, 10)); + const [startDay] = useState(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)); + if (!account) return null; return ( - } - /> - } - /> - } - percent - /> + {features.mastodonAdminMetrics ? ( + } + /> + ) : ( + } + /> + )} + {features.mastodonAdminMetrics ? ( + } + /> + ) : ( + } + /> + )} + {!features.mastodonAdminMetrics && ( + } + percent + /> + )} + {features.mastodonAdminMetrics && ( + <> + } + /> + } + /> + } + /> + + )} { + const client = useClient(); + + return useQuery({ + queryKey: ['admin', 'dimensions', keys, params], + queryFn: () => client.admin.dimensions.getDimensions(keys, params), + enabled: client.features.mastodonAdminMetrics, + }); +}; + +const useMeasures = (keys: AdminMeasureKey[], startAt: string, endAt: string, params?: AdminGetMeasuresParams) => { + const client = useClient(); + + return useQuery({ + queryKey: ['admin', 'measures', keys, startAt, endAt, params], + queryFn: () => client.admin.measures.getMeasures(keys, startAt, endAt, params), + enabled: client.features.mastodonAdminMetrics, + }); +}; + +const useRetention = (startAt: string, endAt: string, frequency: 'day' | 'month') => { + const client = useClient(); + + return useQuery({ + queryKey: ['admin', 'retention', startAt, endAt, frequency], + queryFn: () => client.admin.retention.getRetention(startAt, endAt, frequency), + enabled: client.features.mastodonAdminMetrics, + }); +}; + +export { useDimensions, useMeasures, useRetention };