pl-fe: steal more dashboard code from mastodon

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-04-28 20:37:16 +02:00
parent 0f78abf9a9
commit c634684dbd
13 changed files with 319 additions and 30 deletions

View File

@@ -4411,7 +4411,7 @@ class PlApiClient {
getRetention: async (start_at: string, end_at: string, frequency: 'day' | 'month') => {
const response = await this.request('/api/v1/admin/retention', { method: 'POST', params: { start_at, end_at, frequency } });
return v.parse(adminCohortSchema, response.json);
return v.parse(filteredArray(adminCohortSchema), response.json);
},
},

View File

@@ -12,7 +12,7 @@ const adminCohortSchema = v.object({
data: v.array(v.object({
date: datetimeSchema,
rate: v.number(),
value: v.pipe(v.number(), v.integer()),
value: v.pipe(v.unknown(), v.transform(Number)),
})),
});

View File

@@ -6,13 +6,13 @@ import * as v from 'valibot';
*/
const adminDimensionSchema = v.object({
key: v.string(),
data: v.object({
data: v.array(v.object({
key: v.string(),
human_key: v.string(),
value: v.string(),
unit: v.fallback(v.optional(v.string()), undefined),
human_value: v.fallback(v.optional(v.string()), undefined),
}),
})),
});
/**

View File

@@ -1,6 +1,6 @@
{
"name": "pl-api",
"version": "1.0.0-rc.57",
"version": "1.0.0-rc.58",
"type": "module",
"homepage": "https://github.com/mkljczk/pl-fe/tree/develop/packages/pl-api",
"repository": {

View File

@@ -104,7 +104,7 @@
"multiselect-react-dropdown": "^2.0.25",
"mutative": "^1.1.0",
"path-browserify": "^1.0.1",
"pl-api": "^1.0.0-rc.57",
"pl-api": "^1.0.0-rc.58",
"postcss": "^8.5.3",
"process": "^0.11.10",
"punycode": "^2.1.1",

View File

@@ -79,7 +79,7 @@ const Counter: React.FC<ICounter> = ({
{label}
</Text>
<div>
<div className='mt-auto'>
<Sparklines width={259} height={55} data={data?.[0].data.map(x => x.value * 1) || []}>
<SparklinesCurve />
</Sparklines>
@@ -87,15 +87,17 @@ const Counter: React.FC<ICounter> = ({
</>
);
const className = 'relative flex flex-col rounded bg-gray-200 font-medium dark:bg-gray-800';
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}>
<Link to={to} className={clsx(className, 'transition-transform hover:-translate-y-1')} target={target}>
{inner}
</Link>
);
} else {
return (
<div className='relative rounded bg-gray-200 font-medium dark:bg-gray-800'>
<div className={className}>
{inner}
</div>
);

View File

@@ -45,7 +45,7 @@ interface IDashCounters {
/** Wrapper container for dash counters. */
const DashCounters: React.FC<IDashCounters> = ({ children }) => (
<div className='grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3'>
<div className='grid grid-cols-1 gap-2 sm:grid-cols-2'>
{children}
</div>
);

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { FormattedNumber } from 'react-intl';
import Text from 'pl-fe/components/ui/text';
import { useDimensions } from 'pl-fe/queries/admin/use-metrics';
import type { AdminDimensionKey, AdminGetDimensionsParams } from 'pl-api';
interface IDimension {
dimension: AdminDimensionKey;
startAt: string;
endAt: string;
label: JSX.Element;
params: AdminGetDimensionsParams;
}
const Dimension: React.FC<IDimension> = ({
dimension,
startAt,
endAt,
label,
params,
}) => {
const { data } = useDimensions([dimension], { ...params, start_at: startAt, end_at: endAt });
let content;
if (!data) {
content = (
<table>
<tbody>
{Array.from(Array(params.limit)).map((_, i) => (
<tr key={i}>
<td>
{/* <Skeleton width={100} /> */}
</td>
<td>
{/* <Skeleton width={60} /> */}
</td>
</tr>
))}
</tbody>
</table>
);
} else {
const sum = data[0].data.reduce((sum, cur) => sum + (+cur.value * 1), 0);
content = (
<table className='w-full'>
<tbody>
{data[0].data.map(item => (
<tr className='border-b border-primary-200 last:border-none dark:border-gray-800' key={item.key}>
<td className='p-2.5'>
<span
className='mr-2 inline-block size-2 rounded-full bg-green-500 shadow-sm'
style={{ opacity: +item.value / sum }}
/>
<Text title={item.key} weight='medium' size='xs' tag='span'>{item.human_key}</Text>
</td>
<td className='p-2.5 text-end'>
<Text size='xs'>
{typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={+item.value} />}
</Text>
</td>
</tr>
))}
</tbody>
</table>
);
}
return (
<div>
<Text className='border-b border-primary-200 pb-1 dark:border-gray-800' weight='medium' size='sm'>{label}</Text>
{content}
</div>
);
};
export { Dimension };

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { FormattedDate, FormattedMessage, FormattedNumber } from 'react-intl';
import Text from 'pl-fe/components/ui/text';
import { useRetention } from 'pl-fe/queries/admin/use-metrics';
import type { AdminCohort } from 'pl-api';
const dateForCohort = (cohort: AdminCohort) => {
const timeZone = 'UTC';
switch (cohort.frequency) {
case 'day':
return <FormattedDate value={cohort.period} month='long' day='2-digit' timeZone={timeZone} />;
default:
return <FormattedDate value={cohort.period} month='long' year='numeric' timeZone={timeZone} />;
}
};
interface IRetention {
startAt: string;
endAt: string;
frequency: 'day' | 'month';
}
const Retention: React.FC<IRetention> = ({ startAt, endAt, frequency }) => {
const { data } = useRetention(startAt, endAt, frequency);
let content;
if (!data) {
content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading…' />;
} else {
content = (
<table className='text-xs'>
<thead>
<tr>
<th>
<div className='p-2.5 pl-0 font-bold'>
<FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' />
</div>
</th>
<th>
<div className='p-2.5 font-bold'>
<FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' />
</div>
</th>
{data[0].data.slice(1).map((retention, i) => (
<th key={retention.date} className='w-14'>
<div className='p-2.5 font-bold'>
{i + 1}
</div>
</th>
))}
</tr>
<tr>
<td>
<div className='p-2.5 pl-0 font-bold'>
<FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' />
</div>
</td>
<td>
<div className='p-2.5 text-center'>
<FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
</div>
</td>
{data[0].data.slice(1).map((retention, i) => {
const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum) / (k + 1) : sum, 0);
return (
<td key={retention.date}>
<div className='bg-primary-200 p-2.5 font-medium dark:bg-gray-800' style={{ ['--tw-bg-opacity' as any]: 0.5 + average / 2 }}>
<FormattedNumber value={average} style='percent' />
</div>
</td>
);
})}
</tr>
</thead>
<tbody>
{data.slice(0, -1).map(cohort => (
<tr key={cohort.period}>
<td>
<div className='p-2.5 pl-0'>
{dateForCohort(cohort)}
</div>
</td>
<td>
<div className='p-2.5 text-center'>
<FormattedNumber value={cohort.data[0].value} />
</div>
</td>
{cohort.data.slice(1).map(retention => (
<td key={retention.date}>
<div className='bg-primary-200 p-2.5 font-medium dark:bg-gray-800' style={{ ['--tw-bg-opacity' as any]: 0.5 + retention.rate / 2 }}>
<FormattedNumber value={retention.rate} style='percent' />
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
let title = null;
switch (frequency) {
case 'day':
title = <FormattedMessage id='admin.dashboard.daily_retention' defaultMessage='User retention rate by day after sign-up' />;
break;
default:
title = <FormattedMessage id='admin.dashboard.monthly_retention' defaultMessage='User retention rate by month after sign-up' />;
}
return (
<div className='col-span-2'>
<Text className='border-b border-primary-200 pb-1 dark:border-gray-800' weight='medium' size='sm'>{title}</Text>
{content}
</div>
);
};
export { Retention };

View File

@@ -1,10 +1,11 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import List, { ListItem } from 'pl-fe/components/list';
import { CardTitle } from 'pl-fe/components/ui/card';
import Icon from 'pl-fe/components/ui/icon';
import Stack from 'pl-fe/components/ui/stack';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { useInstance } from 'pl-fe/hooks/use-instance';
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
@@ -12,13 +13,20 @@ import sourceCode from 'pl-fe/utils/code';
import { Counter } from '../components/counter';
import { DashCounter, DashCounters } from '../components/dashcounter';
import { Dimension } from '../components/dimension';
import RegistrationModePicker from '../components/registration-mode-picker';
import { Retention } from '../components/retention';
const Dashboard: React.FC = () => {
const instance = useInstance();
const features = useFeatures();
const { account } = useOwnAccount();
const { pendingReports, pendingUsers } = useAppSelector((state) => ({
pendingReports: state.admin.openReports.length,
pendingUsers: state.admin.awaitingApproval.length,
}));
const v = features.version;
const {
@@ -30,8 +38,9 @@ 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));
const [today] = useState<string>(new Date().toISOString().slice(0, 10));
const [monthAgo] = useState<string>(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10));
const [sixMonthsAgo] = useState<string>(new Date(new Date().getTime() - 30 * 6 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10));
if (!account) return null;
@@ -41,8 +50,8 @@ const Dashboard: React.FC = () => {
{features.mastodonAdminMetrics ? (
<Counter
measure='new_users'
startAt={startDay}
endAt={endDay}
startAt={monthAgo}
endAt={today}
to='/pl-fe/admin/users'
label={<FormattedMessage id='admin.counters.new_users' defaultMessage='new users' />}
/>
@@ -56,8 +65,8 @@ const Dashboard: React.FC = () => {
{features.mastodonAdminMetrics ? (
<Counter
measure='active_users'
startAt={startDay}
endAt={endDay}
startAt={monthAgo}
endAt={today}
label={<FormattedMessage id='admin.counters.active_users' defaultMessage='active users' />}
/>
) : (
@@ -77,21 +86,21 @@ const Dashboard: React.FC = () => {
<>
<Counter
measure='interactions'
startAt={startDay}
endAt={endDay}
startAt={monthAgo}
endAt={today}
label={<FormattedMessage id='admin.counters.interactions' defaultMessage='interactions' />}
/>
<Counter
measure='opened_reports'
startAt={startDay}
endAt={endDay}
startAt={monthAgo}
endAt={today}
to='/pl-fe/admin/reports'
label={<FormattedMessage id='admin.counters.opened_reports' defaultMessage='reports opened' />}
/>
<Counter
measure='resolved_reports'
startAt={startDay}
endAt={endDay}
startAt={monthAgo}
endAt={today}
to='/pl-fe/admin/reports'
label={<FormattedMessage id='admin.counters.resolved_reports' defaultMessage='reports resolved' />}
/>
@@ -106,6 +115,52 @@ const Dashboard: React.FC = () => {
count={domainCount}
label={<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />}
/>
<List>
<ListItem size='sm' to='/pl-fe/admin/reports' label={<FormattedMessage id='admin.links.pending_reports' defaultMessage='{count, plural, one {{formattedCount} pending report} other {{formattedCount} pending reports}}' values={{ count: pendingReports, formattedCount: <strong><FormattedNumber value={pendingReports} /></strong> }} />} />
<ListItem size='sm' to='/pl-fe/admin/users' label={<FormattedMessage id='admin.links.pending_users' defaultMessage='{count, plural, one {{formattedCount} pending user} other {{formattedCount} pending users}}' values={{ count: pendingUsers, formattedCount: <strong><FormattedNumber value={pendingUsers} /></strong> }} />} />
{/* <ListItem size='sm' to='/pl-fe/admin' label={<FormattedMessage id='admin.links.pending_tags' defaultMessage='{count} pending tags' values={{ count: <strong>0</strong> }} />} />
<ListItem size='sm' to='/pl-fe/admin' label={<FormattedMessage id='admin.links.pending_appeals' defaultMessage='{count} pending appeals' values={{ count: <strong>0</strong> }} />} /> */}
</List>
{features.mastodonAdminMetrics && (
<>
<Dimension
dimension='sources'
startAt={monthAgo}
endAt={today}
params={{ limit: 8 }}
label={<FormattedMessage id='admin.dimensions.sources' defaultMessage='Sign-up sources' />}
/>
<Dimension
dimension='languages'
startAt={monthAgo}
endAt={today}
params={{ limit: 8 }}
label={<FormattedMessage id='admin.dimensions.top_languages' defaultMessage='Top active languages' />}
/>
<Dimension
dimension='servers'
startAt={monthAgo}
endAt={today}
params={{ limit: 8 }}
label={<FormattedMessage id='admin.dimensions.top_servers' defaultMessage='Top active servers' />}
/>
<Retention startAt={sixMonthsAgo} endAt={today} frequency='month' />
<Dimension
dimension='software_versions'
startAt={monthAgo}
endAt={today}
params={{ limit: 4 }}
label={<FormattedMessage id='admin.dimensions.software' defaultMessage='Software' />}
/>
<Dimension
dimension='space_usage'
startAt={monthAgo}
endAt={today}
params={{ limit: 3 }}
label={<FormattedMessage id='admin.dimensions.media_storage' defaultMessage='Media storage' />}
/>
</>
)}
</DashCounters>
<List>
@@ -175,9 +230,11 @@ const Dashboard: React.FC = () => {
</a>
</ListItem>
<ListItem label={<FormattedMessage id='admin.software.backend' defaultMessage='Backend' />}>
<span>{v.software + (v.build ? `+${v.build}` : '')} {v.version}</span>
</ListItem>
{!features.mastodonAdminMetrics && (
<ListItem label={<FormattedMessage id='admin.software.backend' defaultMessage='Backend' />}>
<span>{v.software + (v.build ? `+${v.build}` : '')} {v.version}</span>
</ListItem>
)}
</List>
</Stack>
);

View File

@@ -107,6 +107,8 @@
"admin.counters.new_users": "new users",
"admin.counters.opened_reports": "reports opened",
"admin.counters.resolved_reports": "reports resolved",
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
"admin.dashboard.registration_mode.approval_hint": "Users can sign up, but their account only gets activated when an admin approves it.",
"admin.dashboard.registration_mode.approval_label": "Approval Required",
"admin.dashboard.registration_mode.closed_hint": "Nobody can sign up. You can still invite people.",
@@ -114,6 +116,9 @@
"admin.dashboard.registration_mode.open_hint": "Anyone can join.",
"admin.dashboard.registration_mode.open_label": "Open",
"admin.dashboard.registration_mode_label": "Registrations",
"admin.dashboard.retention.average": "Average",
"admin.dashboard.retention.cohort": "Sign-up month",
"admin.dashboard.retention.cohort_size": "New users",
"admin.dashboard.settings_saved": "Settings saved!",
"admin.dashcounters.domain_count_label": "peers",
"admin.dashcounters.mau_label": "monthly active users",
@@ -121,6 +126,11 @@
"admin.dashcounters.status_count_label": "posts",
"admin.dashcounters.user_count_label": "total users",
"admin.dashwidgets.software_header": "Software",
"admin.dimensions.media_storage": "Media storage",
"admin.dimensions.software": "Software",
"admin.dimensions.sources": "Sign-up sources",
"admin.dimensions.top_languages": "Top active languages",
"admin.dimensions.top_servers": "Top active servers",
"admin.domains.action": "Create domain",
"admin.domains.delete": "Delete",
"admin.domains.edit": "Edit",
@@ -161,6 +171,8 @@
"admin.edit_rule.updated": "Rule edited",
"admin.latest_accounts_panel.more": "Click to see {count, plural, one {# account} other {# accounts}}",
"admin.latest_accounts_panel.title": "Latest Accounts",
"admin.links.pending_reports": "{count, plural, one {{formattedCount} pending report} other {{formattedCount} pending reports}}",
"admin.links.pending_users": "{count, plural, one {{formattedCount} pending user} other {{formattedCount} pending users}}",
"admin.moderation_log.empty_message": "You have not performed any moderation actions yet. When you do, a history will be shown here.",
"admin.relays.add.fail": "Failed to follow the instance relay",
"admin.relays.add.success": "Instance relay followed",

View File

@@ -49,9 +49,12 @@ const shortNumberFormat = (number: any, max?: number): React.ReactNode => {
/** Check if an entity ID is an integer (eg not a FlakeId). */
const isIntegerId = (id: string): boolean => new RegExp(/^-?[0-9]+$/g).test(id);
const roundTo10 = (num: number) => Math.round(num / 10) * 10;
export {
isNumber,
roundDown,
shortNumberFormat,
isIntegerId,
roundTo10,
};

View File

@@ -6858,10 +6858,10 @@ pkg-dir@^4.1.0:
dependencies:
find-up "^4.0.0"
pl-api@^1.0.0-rc.57:
version "1.0.0-rc.57"
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-1.0.0-rc.57.tgz#848259e4a38e3c44dc3048a86578d877b36e50e7"
integrity sha512-ndT9fnL0LJt5EMRMHWRovAd/YE8w6Ya/Ow1TUbiwM760IqQUAIyxBNt5gLB89OlkbJQiVC+RD/vVUxBdBMisLg==
pl-api@^1.0.0-rc.58:
version "1.0.0-rc.58"
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-1.0.0-rc.58.tgz#3605128a53029ef4abd8316fdabe3ab4e0c2552a"
integrity sha512-GR/gXEQGwo1SueSwPzPsRUeuTpulRx5Wpd3y6/bOC7r8BGJyKK0FI34/y3PYLEzBveYo5W2JP+tCI4dUKk+akQ==
dependencies:
blurhash "^2.0.5"
http-link-header "^1.1.3"