diff --git a/packages/pl-api/lib/client.ts b/packages/pl-api/lib/client.ts index 22560a475..aa825fd5c 100644 --- a/packages/pl-api/lib/client.ts +++ b/packages/pl-api/lib/client.ts @@ -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); }, }, diff --git a/packages/pl-api/lib/entities/admin/cohort.ts b/packages/pl-api/lib/entities/admin/cohort.ts index ceaa51641..824f240e9 100644 --- a/packages/pl-api/lib/entities/admin/cohort.ts +++ b/packages/pl-api/lib/entities/admin/cohort.ts @@ -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)), })), }); diff --git a/packages/pl-api/lib/entities/admin/dimension.ts b/packages/pl-api/lib/entities/admin/dimension.ts index 1804f90dc..4682bcc09 100644 --- a/packages/pl-api/lib/entities/admin/dimension.ts +++ b/packages/pl-api/lib/entities/admin/dimension.ts @@ -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), - }), + })), }); /** diff --git a/packages/pl-api/package.json b/packages/pl-api/package.json index cbd8efc66..afaf89cb8 100644 --- a/packages/pl-api/package.json +++ b/packages/pl-api/package.json @@ -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": { diff --git a/packages/pl-fe/package.json b/packages/pl-fe/package.json index cbc3d6935..9771dd5d5 100644 --- a/packages/pl-fe/package.json +++ b/packages/pl-fe/package.json @@ -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", diff --git a/packages/pl-fe/src/features/admin/components/counter.tsx b/packages/pl-fe/src/features/admin/components/counter.tsx index b876a89e0..0a53f20a1 100644 --- a/packages/pl-fe/src/features/admin/components/counter.tsx +++ b/packages/pl-fe/src/features/admin/components/counter.tsx @@ -79,7 +79,7 @@ const Counter: React.FC = ({ {label} -
+
x.value * 1) || []}> @@ -87,15 +87,17 @@ const Counter: React.FC = ({ ); + const className = 'relative flex flex-col rounded bg-gray-200 font-medium dark:bg-gray-800'; + if (to) { return ( - + {inner} ); } else { return ( -
+
{inner}
); diff --git a/packages/pl-fe/src/features/admin/components/dashcounter.tsx b/packages/pl-fe/src/features/admin/components/dashcounter.tsx index a65a9b7ae..d2d1d4eec 100644 --- a/packages/pl-fe/src/features/admin/components/dashcounter.tsx +++ b/packages/pl-fe/src/features/admin/components/dashcounter.tsx @@ -45,7 +45,7 @@ interface IDashCounters { /** Wrapper container for dash counters. */ const DashCounters: React.FC = ({ children }) => ( -
+
{children}
); diff --git a/packages/pl-fe/src/features/admin/components/dimension.tsx b/packages/pl-fe/src/features/admin/components/dimension.tsx new file mode 100644 index 000000000..9eb1c2d39 --- /dev/null +++ b/packages/pl-fe/src/features/admin/components/dimension.tsx @@ -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 = ({ + dimension, + startAt, + endAt, + label, + params, +}) => { + const { data } = useDimensions([dimension], { ...params, start_at: startAt, end_at: endAt }); + + let content; + + if (!data) { + content = ( + + + {Array.from(Array(params.limit)).map((_, i) => ( + + + + + + ))} + +
+ {/* */} + + {/* */} +
+ ); + } else { + const sum = data[0].data.reduce((sum, cur) => sum + (+cur.value * 1), 0); + + content = ( + + + {data[0].data.map(item => ( + + + + + + ))} + +
+ + {item.human_key} + + + {typeof item.human_value !== 'undefined' ? item.human_value : } + +
+ ); + } + + return ( +
+ {label} + + {content} +
+ ); +}; + +export { Dimension }; diff --git a/packages/pl-fe/src/features/admin/components/retention.tsx b/packages/pl-fe/src/features/admin/components/retention.tsx new file mode 100644 index 000000000..fbb485f0c --- /dev/null +++ b/packages/pl-fe/src/features/admin/components/retention.tsx @@ -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 ; + default: + return ; + } +}; + +interface IRetention { + startAt: string; + endAt: string; + frequency: 'day' | 'month'; +} + +const Retention: React.FC = ({ startAt, endAt, frequency }) => { + const { data } = useRetention(startAt, endAt, frequency); + + let content; + + if (!data) { + content = ; + } else { + content = ( + + + + + + + + {data[0].data.slice(1).map((retention, i) => ( + + ))} + + + + + + + + {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 ( + + ); + })} + + + + + {data.slice(0, -1).map(cohort => ( + + + + + + {cohort.data.slice(1).map(retention => ( + + ))} + + ))} + +
+
+ +
+
+
+ +
+
+
+ {i + 1} +
+
+
+ +
+
+
+ sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} /> +
+
+
+ +
+
+
+ {dateForCohort(cohort)} +
+
+
+ +
+
+
+ +
+
+ ); + } + + let title = null; + switch (frequency) { + case 'day': + title = ; + break; + default: + title = ; + } + + return ( +
+ {title} + + {content} +
+ ); +}; + +export { Retention }; diff --git a/packages/pl-fe/src/features/admin/tabs/dashboard.tsx b/packages/pl-fe/src/features/admin/tabs/dashboard.tsx index c76ff9a95..0d0656949 100644 --- a/packages/pl-fe/src/features/admin/tabs/dashboard.tsx +++ b/packages/pl-fe/src/features/admin/tabs/dashboard.tsx @@ -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(new Date().toISOString().slice(0, 10)); - const [startDay] = useState(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)); + const [today] = useState(new Date().toISOString().slice(0, 10)); + const [monthAgo] = useState(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)); + const [sixMonthsAgo] = useState(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 ? ( } /> @@ -56,8 +65,8 @@ const Dashboard: React.FC = () => { {features.mastodonAdminMetrics ? ( } /> ) : ( @@ -77,21 +86,21 @@ const Dashboard: React.FC = () => { <> } /> } /> } /> @@ -106,6 +115,52 @@ const Dashboard: React.FC = () => { count={domainCount} label={} /> + + }} />} /> + }} />} /> + {/* 0 }} />} /> + 0 }} />} /> */} + + {features.mastodonAdminMetrics && ( + <> + } + /> + } + /> + } + /> + + } + /> + } + /> + + )} @@ -175,9 +230,11 @@ const Dashboard: React.FC = () => { - }> - {v.software + (v.build ? `+${v.build}` : '')} {v.version} - + {!features.mastodonAdminMetrics && ( + }> + {v.software + (v.build ? `+${v.build}` : '')} {v.version} + + )} ); diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index b01e923bf..0a45544a8 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -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", diff --git a/packages/pl-fe/src/utils/numbers.tsx b/packages/pl-fe/src/utils/numbers.tsx index 3524dbcee..c972e640f 100644 --- a/packages/pl-fe/src/utils/numbers.tsx +++ b/packages/pl-fe/src/utils/numbers.tsx @@ -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, }; diff --git a/packages/pl-fe/yarn.lock b/packages/pl-fe/yarn.lock index 0902756e2..3750c8cd5 100644 --- a/packages/pl-fe/yarn.lock +++ b/packages/pl-fe/yarn.lock @@ -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"