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:
@ -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);
|
||||
},
|
||||
|
||||
@ -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)),
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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/}
|
||||
|
||||
105
packages/pl-fe/src/features/admin/components/counter.tsx
Normal file
105
packages/pl-fe/src/features/admin/components/counter.tsx
Normal 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 };
|
||||
@ -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}
|
||||
|
||||
37
packages/pl-fe/src/queries/admin/use-metrics.ts
Normal file
37
packages/pl-fe/src/queries/admin/use-metrics.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user