pl-fe: steal code from mastodon, pl-api: masto admin api fixes

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-04-26 19:32:04 +02:00
parent 1f857a3e14
commit 0d59691dba
7 changed files with 224 additions and 23 deletions

View File

@ -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);
},

View File

@ -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)),
})),
});

View File

@ -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 = <T>(schema: v.BaseSchema<any, T, v.BaseIssue<unknown>>) =>
v.pipe(
@ -35,4 +37,4 @@ const coerceObject = <T extends v.ObjectEntries>(shape: T) =>
v.object(shape),
);
export { filteredArray, emojiSchema, datetimeSchema, mimeSchema, coerceObject };
export { filteredArray, emojiSchema, datetimeSchema, dateSchema, mimeSchema, coerceObject };

View File

@ -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/}

View File

@ -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<ICounter> = ({
measure,
startAt,
endAt,
label,
to,
params,
target,
}) => {
const { data } = useMeasures([measure], startAt, endAt, params);
let content;
if (!data) {
content = (
<>
{/* <span className='sparkline__value__total'><Skeleton width={43} /></span>
<span className='sparkline__value__change'><Skeleton width={43} /></span> */}
</>
);
} else {
const measure = data![0];
const percentChange = measure.previous_total !== undefined && percIncrease(measure.previous_total * 1, measure.total * 1) || 0;
content = (
<>
<Text tag='span' align='center' size='2xl' weight='medium'>{measure.human_value || <FormattedNumber value={measure.total} />}</Text>
{measure.previous_total !== undefined && (<span className={clsx('text-lg', { 'text-green-600': percentChange > 0, 'text-danger-600': percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>)}
</>
);
}
const inner = (
<>
<div className='flex items-end justify-center gap-2.5 px-5 pb-2 pt-4 leading-[33px]'>
{content}
</div>
<Text align='center'>
{label}
</Text>
<div>
<Sparklines width={259} height={55} data={data?.[0].data.map(x => x.value * 1) || []}>
<SparklinesCurve />
</Sparklines>
</div>
</>
);
if (to) {
return (
<Link to={to} className='relative rounded bg-gray-200 font-medium transition-transform hover:-translate-y-1 dark:bg-gray-800' target={target}>
{inner}
</Link>
);
} else {
return (
<div className='relative rounded bg-gray-200 font-medium dark:bg-gray-800'>
{inner}
</div>
);
}
};
export { Counter };

View File

@ -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<string>(new Date().toISOString().slice(0, 10));
const [startDay] = useState<string>(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10));
if (!account) return null;
return (
<Stack space={6} className='mt-4'>
<DashCounters>
<DashCounter
count={mau}
label={<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' />}
/>
<DashCounter
to='/pl-fe/admin/users'
count={userCount}
label={<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' />}
/>
<DashCounter
count={retention}
label={<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' />}
percent
/>
{features.mastodonAdminMetrics ? (
<Counter
measure='new_users'
startAt={startDay}
endAt={endDay}
to='/pl-fe/admin/users'
label={<FormattedMessage id='admin.counters.new_users' defaultMessage='new users' />}
/>
) : (
<DashCounter
to='/pl-fe/admin/users'
count={userCount}
label={<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' />}
/>
)}
{features.mastodonAdminMetrics ? (
<Counter
measure='active_users'
startAt={startDay}
endAt={endDay}
label={<FormattedMessage id='admin.counters.active_users' defaultMessage='active users' />}
/>
) : (
<DashCounter
count={mau}
label={<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' />}
/>
)}
{!features.mastodonAdminMetrics && (
<DashCounter
count={retention}
label={<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' />}
percent
/>
)}
{features.mastodonAdminMetrics && (
<>
<Counter
measure='interactions'
startAt={startDay}
endAt={endDay}
label={<FormattedMessage id='admin.counters.interactions' defaultMessage='interactions' />}
/>
<Counter
measure='opened_reports'
startAt={startDay}
endAt={endDay}
to='/pl-fe/admin/reports'
label={<FormattedMessage id='admin.counters.opened_reports' defaultMessage='reports opened' />}
/>
<Counter
measure='resolved_reports'
startAt={startDay}
endAt={endDay}
to='/pl-fe/admin/reports'
label={<FormattedMessage id='admin.counters.resolved_reports' defaultMessage='reports resolved' />}
/>
</>
)}
<DashCounter
to='/timeline/local'
count={statusCount}

View File

@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'pl-fe/hooks/use-client';
import type { AdminDimensionKey, AdminGetDimensionsParams, AdminGetMeasuresParams, AdminMeasureKey } from 'pl-api';
const useDimensions = (keys: AdminDimensionKey[], params?: AdminGetDimensionsParams) => {
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 };