Add graphs and retention metrics to admin dashboard (#16829)
parent
15d35ed5fa
commit
cad446611c
|
@ -1,49 +1,17 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
require 'sidekiq/api'
|
|
||||||
|
|
||||||
module Admin
|
module Admin
|
||||||
class DashboardController < BaseController
|
class DashboardController < BaseController
|
||||||
def index
|
def index
|
||||||
@system_checks = Admin::SystemCheck.perform
|
@system_checks = Admin::SystemCheck.perform
|
||||||
@users_count = User.count
|
@time_period = (1.month.ago.to_date...Time.now.utc.to_date)
|
||||||
@pending_users_count = User.pending.count
|
@pending_users_count = User.pending.count
|
||||||
@registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0
|
@pending_reports_count = Report.unresolved.count
|
||||||
@logins_week = Redis.current.pfcount("activity:logins:#{current_week}")
|
|
||||||
@interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0
|
|
||||||
@relay_enabled = Relay.enabled.exists?
|
|
||||||
@single_user_mode = Rails.configuration.x.single_user_mode
|
|
||||||
@registrations_enabled = Setting.registrations_mode != 'none'
|
|
||||||
@deletions_enabled = Setting.open_deletion
|
|
||||||
@invites_enabled = Setting.min_invite_role == 'user'
|
|
||||||
@search_enabled = Chewy.enabled?
|
|
||||||
@version = Mastodon::Version.to_s
|
|
||||||
@database_version = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1]
|
|
||||||
@redis_version = redis_info['redis_version']
|
|
||||||
@reports_count = Report.unresolved.count
|
|
||||||
@queue_backlog = Sidekiq::Stats.new.enqueued
|
|
||||||
@recent_users = User.confirmed.recent.includes(:account).limit(8)
|
|
||||||
@database_size = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size']
|
|
||||||
@redis_size = redis_info['used_memory']
|
|
||||||
@ldap_enabled = ENV['LDAP_ENABLED'] == 'true'
|
|
||||||
@cas_enabled = ENV['CAS_ENABLED'] == 'true'
|
|
||||||
@saml_enabled = ENV['SAML_ENABLED'] == 'true'
|
|
||||||
@pam_enabled = ENV['PAM_ENABLED'] == 'true'
|
|
||||||
@hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
|
|
||||||
@trending_hashtags = TrendingTags.get(10, filtered: false)
|
|
||||||
@pending_tags_count = Tag.pending_review.count
|
@pending_tags_count = Tag.pending_review.count
|
||||||
@authorized_fetch = authorized_fetch_mode?
|
|
||||||
@whitelist_enabled = whitelist_mode?
|
|
||||||
@profile_directory = Setting.profile_directory
|
|
||||||
@timeline_preview = Setting.timeline_preview
|
|
||||||
@trends_enabled = Setting.trends
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def current_week
|
|
||||||
@current_week ||= Time.now.utc.to_date.cweek
|
|
||||||
end
|
|
||||||
|
|
||||||
def redis_info
|
def redis_info
|
||||||
@redis_info ||= begin
|
@redis_info ||= begin
|
||||||
if Redis.current.is_a?(Redis::Namespace)
|
if Redis.current.is_a?(Redis::Namespace)
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Admin::DimensionsController < Api::BaseController
|
||||||
|
protect_from_forgery with: :exception
|
||||||
|
|
||||||
|
before_action :require_staff!
|
||||||
|
before_action :set_dimensions
|
||||||
|
|
||||||
|
def create
|
||||||
|
render json: @dimensions, each_serializer: REST::Admin::DimensionSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_dimensions
|
||||||
|
@dimensions = Admin::Metrics::Dimension.retrieve(
|
||||||
|
params[:keys],
|
||||||
|
params[:start_at],
|
||||||
|
params[:end_at],
|
||||||
|
params[:limit]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,22 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Admin::MeasuresController < Api::BaseController
|
||||||
|
protect_from_forgery with: :exception
|
||||||
|
|
||||||
|
before_action :require_staff!
|
||||||
|
before_action :set_measures
|
||||||
|
|
||||||
|
def create
|
||||||
|
render json: @measures, each_serializer: REST::Admin::MeasureSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_measures
|
||||||
|
@measures = Admin::Metrics::Measure.retrieve(
|
||||||
|
params[:keys],
|
||||||
|
params[:start_at],
|
||||||
|
params[:end_at]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,22 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Admin::RetentionController < Api::BaseController
|
||||||
|
protect_from_forgery with: :exception
|
||||||
|
|
||||||
|
before_action :require_staff!
|
||||||
|
before_action :set_cohorts
|
||||||
|
|
||||||
|
def create
|
||||||
|
render json: @cohorts, each_serializer: REST::Admin::CohortSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_cohorts
|
||||||
|
@cohorts = Admin::Metrics::Retention.new(
|
||||||
|
params[:start_at],
|
||||||
|
params[:end_at],
|
||||||
|
params[:frequency]
|
||||||
|
).cohorts
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,16 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Admin::TrendsController < Api::BaseController
|
||||||
|
before_action :require_staff!
|
||||||
|
before_action :set_trends
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: @trends, each_serializer: REST::Admin::TagSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_trends
|
||||||
|
@trends = TrendingTags.get(10, filtered: false)
|
||||||
|
end
|
||||||
|
end
|
|
@ -14,22 +14,21 @@ class Api::V1::Instances::ActivityController < Api::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def activity
|
def activity
|
||||||
weeks = []
|
statuses_tracker = ActivityTracker.new('activity:statuses:local', :basic)
|
||||||
|
logins_tracker = ActivityTracker.new('activity:logins', :unique)
|
||||||
|
registrations_tracker = ActivityTracker.new('activity:accounts:local', :basic)
|
||||||
|
|
||||||
12.times do |i|
|
(0...12).map do |i|
|
||||||
day = i.weeks.ago.to_date
|
start_of_week = i.weeks.ago
|
||||||
week_id = day.cweek
|
end_of_week = start_of_week + 6.days
|
||||||
week = Date.commercial(day.cwyear, week_id)
|
|
||||||
|
|
||||||
weeks << {
|
{
|
||||||
week: week.to_time.to_i.to_s,
|
week: start_of_week.to_i.to_s,
|
||||||
statuses: Redis.current.get("activity:statuses:local:#{week_id}") || '0',
|
statuses: statuses_tracker.sum(start_of_week, end_of_week).to_s,
|
||||||
logins: Redis.current.pfcount("activity:logins:#{week_id}").to_s,
|
logins: logins_tracker.sum(start_of_week, end_of_week).to_s,
|
||||||
registrations: Redis.current.get("activity:accounts:local:#{week_id}") || '0',
|
registrations: registrations_tracker.sum(start_of_week, end_of_week).to_s,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
weeks
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_enabled_api!
|
def require_enabled_api!
|
||||||
|
|
|
@ -137,6 +137,10 @@ module ApplicationHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def react_admin_component(name, props = {})
|
||||||
|
content_tag(:div, nil, data: { 'admin-component': name.to_s.camelcase, props: Oj.dump({ locale: I18n.locale }.merge(props)) })
|
||||||
|
end
|
||||||
|
|
||||||
def body_classes
|
def body_classes
|
||||||
output = (@body_classes || '').split(' ')
|
output = (@body_classes || '').split(' ')
|
||||||
output << "theme-#{current_theme.parameterize}"
|
output << "theme-#{current_theme.parameterize}"
|
||||||
|
|
|
@ -0,0 +1,115 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import api from 'mastodon/api';
|
||||||
|
import { FormattedNumber } from 'react-intl';
|
||||||
|
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import Skeleton from 'mastodon/components/skeleton';
|
||||||
|
|
||||||
|
const percIncrease = (a, b) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Counter extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
measure: PropTypes.string.isRequired,
|
||||||
|
start_at: PropTypes.string.isRequired,
|
||||||
|
end_at: PropTypes.string.isRequired,
|
||||||
|
label: PropTypes.string.isRequired,
|
||||||
|
href: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
loading: true,
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { measure, start_at, end_at } = this.props;
|
||||||
|
|
||||||
|
api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at }).then(res => {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
data: res.data,
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { label, href } = this.props;
|
||||||
|
const { loading, data } = this.state;
|
||||||
|
|
||||||
|
let content;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
content = (
|
||||||
|
<React.Fragment>
|
||||||
|
<span className='sparkline__value__total'><Skeleton width={43} /></span>
|
||||||
|
<span className='sparkline__value__change'><Skeleton width={43} /></span>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const measure = data[0];
|
||||||
|
const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1);
|
||||||
|
|
||||||
|
content = (
|
||||||
|
<React.Fragment>
|
||||||
|
<span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span>
|
||||||
|
<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className='sparkline__value'>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='sparkline__label'>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='sparkline__graph'>
|
||||||
|
{!loading && (
|
||||||
|
<Sparklines width={259} height={55} data={data[0].data.map(x => x.value * 1)}>
|
||||||
|
<SparklinesCurve />
|
||||||
|
</Sparklines>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<a href={href} className='sparkline'>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className='sparkline'>
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import api from 'mastodon/api';
|
||||||
|
import { FormattedNumber } from 'react-intl';
|
||||||
|
import { roundTo10 } from 'mastodon/utils/numbers';
|
||||||
|
import Skeleton from 'mastodon/components/skeleton';
|
||||||
|
|
||||||
|
export default class Dimension extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
dimension: PropTypes.string.isRequired,
|
||||||
|
start_at: PropTypes.string.isRequired,
|
||||||
|
end_at: PropTypes.string.isRequired,
|
||||||
|
limit: PropTypes.number.isRequired,
|
||||||
|
label: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
loading: true,
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { start_at, end_at, dimension, limit } = this.props;
|
||||||
|
|
||||||
|
api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit }).then(res => {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
data: res.data,
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { label, limit } = this.props;
|
||||||
|
const { loading, data } = this.state;
|
||||||
|
|
||||||
|
let content;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
content = (
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{Array.from(Array(limit)).map((_, i) => (
|
||||||
|
<tr className='dimension__item' key={i}>
|
||||||
|
<td className='dimension__item__key'>
|
||||||
|
<Skeleton width={100} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className='dimension__item__value'>
|
||||||
|
<Skeleton width={60} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0);
|
||||||
|
|
||||||
|
content = (
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{data[0].data.map(item => (
|
||||||
|
<tr className='dimension__item' key={item.key}>
|
||||||
|
<td className='dimension__item__key'>
|
||||||
|
<span className={`dimension__item__indicator dimension__item__indicator--${roundTo10(((item.value * 1) / sum) * 100)}`} />
|
||||||
|
<span title={item.key}>{item.human_key}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className='dimension__item__value'>
|
||||||
|
{typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={item.value} />}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='dimension'>
|
||||||
|
<h4>{label}</h4>
|
||||||
|
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,141 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import api from 'mastodon/api';
|
||||||
|
import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { roundTo10 } from 'mastodon/utils/numbers';
|
||||||
|
|
||||||
|
const dateForCohort = cohort => {
|
||||||
|
switch(cohort.frequency) {
|
||||||
|
case 'day':
|
||||||
|
return <FormattedDate value={cohort.period} month='long' day='2-digit' />;
|
||||||
|
default:
|
||||||
|
return <FormattedDate value={cohort.period} month='long' year='numeric' />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Retention extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
start_at: PropTypes.string,
|
||||||
|
end_at: PropTypes.string,
|
||||||
|
frequency: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
loading: true,
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { start_at, end_at, frequency } = this.props;
|
||||||
|
|
||||||
|
api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
data: res.data,
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { loading, data } = this.state;
|
||||||
|
|
||||||
|
let content;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />;
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<table className='retention__table'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<div className='retention__table__date retention__table__label'>
|
||||||
|
<FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' />
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
<div className='retention__table__number retention__table__label'>
|
||||||
|
<FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' />
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
|
||||||
|
{data[0].data.slice(1).map((retention, i) => (
|
||||||
|
<th key={retention.date}>
|
||||||
|
<div className='retention__table__number retention__table__label'>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div className='retention__table__date retention__table__average'>
|
||||||
|
<FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div className='retention__table__size'>
|
||||||
|
<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].percent - sum)/(k + 1) : sum, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td key={retention.date}>
|
||||||
|
<div className={classNames('retention__table__box', 'retention__table__average', `retention__table__box--${roundTo10(average * 100)}`)}>
|
||||||
|
<FormattedNumber value={average} style='percent' />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{data.slice(0, -1).map(cohort => (
|
||||||
|
<tr key={cohort.period}>
|
||||||
|
<td>
|
||||||
|
<div className='retention__table__date'>
|
||||||
|
{dateForCohort(cohort)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div className='retention__table__size'>
|
||||||
|
<FormattedNumber value={cohort.data[0].value} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{cohort.data.slice(1).map(retention => (
|
||||||
|
<td key={retention.date}>
|
||||||
|
<div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.percent * 100)}`)}>
|
||||||
|
<FormattedNumber value={retention.percent} style='percent' />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='retention'>
|
||||||
|
<h4><FormattedMessage id='admin.dashboard.retention' defaultMessage='Retention' /></h4>
|
||||||
|
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import api from 'mastodon/api';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import Hashtag from 'mastodon/components/hashtag';
|
||||||
|
|
||||||
|
export default class Trends extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
limit: PropTypes.number.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
loading: true,
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { limit } = this.props;
|
||||||
|
|
||||||
|
api().get('/api/v1/admin/trends', { params: { limit } }).then(res => {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
data: res.data,
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { limit } = this.props;
|
||||||
|
const { loading, data } = this.state;
|
||||||
|
|
||||||
|
let content;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
content = (
|
||||||
|
<div>
|
||||||
|
{Array.from(Array(limit)).map((_, i) => (
|
||||||
|
<Hashtag key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<div>
|
||||||
|
{data.map(hashtag => (
|
||||||
|
<Hashtag
|
||||||
|
key={hashtag.name}
|
||||||
|
name={hashtag.name}
|
||||||
|
href={`/admin/tags/${hashtag.id}`}
|
||||||
|
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
|
||||||
|
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
|
||||||
|
history={hashtag.history.reverse().map(day => day.uses)}
|
||||||
|
className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='trends trends--compact'>
|
||||||
|
<h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
|
||||||
|
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -6,6 +6,8 @@ import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Permalink from './permalink';
|
import Permalink from './permalink';
|
||||||
import ShortNumber from 'mastodon/components/short_number';
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
|
import Skeleton from 'mastodon/components/skeleton';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
class SilentErrorBoundary extends React.Component {
|
class SilentErrorBoundary extends React.Component {
|
||||||
|
|
||||||
|
@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => (
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Hashtag = ({ hashtag }) => (
|
export const ImmutableHashtag = ({ hashtag }) => (
|
||||||
<div className='trends__item'>
|
<Hashtag
|
||||||
|
name={hashtag.get('name')}
|
||||||
|
href={hashtag.get('url')}
|
||||||
|
to={`/tags/${hashtag.get('name')}`}
|
||||||
|
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
|
||||||
|
uses={hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1}
|
||||||
|
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
ImmutableHashtag.propTypes = {
|
||||||
|
hashtag: ImmutablePropTypes.map.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Hashtag = ({ name, href, to, people, uses, history, className }) => (
|
||||||
|
<div className={classNames('trends__item', className)}>
|
||||||
<div className='trends__item__name'>
|
<div className='trends__item__name'>
|
||||||
<Permalink
|
<Permalink href={href} to={to}>
|
||||||
href={hashtag.get('url')}
|
{name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
|
||||||
to={`/tags/${hashtag.get('name')}`}
|
|
||||||
>
|
|
||||||
#<span>{hashtag.get('name')}</span>
|
|
||||||
</Permalink>
|
</Permalink>
|
||||||
|
|
||||||
<ShortNumber
|
{typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
|
||||||
value={
|
|
||||||
hashtag.getIn(['history', 0, 'accounts']) * 1 +
|
|
||||||
hashtag.getIn(['history', 1, 'accounts']) * 1
|
|
||||||
}
|
|
||||||
renderer={accountsCountRenderer}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='trends__item__current'>
|
<div className='trends__item__current'>
|
||||||
<ShortNumber
|
{typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
|
||||||
value={
|
|
||||||
hashtag.getIn(['history', 0, 'uses']) * 1 +
|
|
||||||
hashtag.getIn(['history', 1, 'uses']) * 1
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='trends__item__sparkline'>
|
<div className='trends__item__sparkline'>
|
||||||
<SilentErrorBoundary>
|
<SilentErrorBoundary>
|
||||||
<Sparklines
|
<Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
|
||||||
width={50}
|
|
||||||
height={28}
|
|
||||||
data={hashtag
|
|
||||||
.get('history')
|
|
||||||
.reverse()
|
|
||||||
.map((day) => day.get('uses'))
|
|
||||||
.toArray()}
|
|
||||||
>
|
|
||||||
<SparklinesCurve style={{ fill: 'none' }} />
|
<SparklinesCurve style={{ fill: 'none' }} />
|
||||||
</Sparklines>
|
</Sparklines>
|
||||||
</SilentErrorBoundary>
|
</SilentErrorBoundary>
|
||||||
|
@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => (
|
||||||
);
|
);
|
||||||
|
|
||||||
Hashtag.propTypes = {
|
Hashtag.propTypes = {
|
||||||
hashtag: ImmutablePropTypes.map.isRequired,
|
name: PropTypes.string,
|
||||||
|
href: PropTypes.string,
|
||||||
|
to: PropTypes.string,
|
||||||
|
people: PropTypes.number,
|
||||||
|
uses: PropTypes.number,
|
||||||
|
history: PropTypes.arrayOf(PropTypes.number),
|
||||||
|
className: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Hashtag;
|
export default Hashtag;
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>‌</span>;
|
||||||
|
|
||||||
|
Skeleton.propTypes = {
|
||||||
|
width: PropTypes.number,
|
||||||
|
height: PropTypes.number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Skeleton;
|
|
@ -0,0 +1,26 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||||
|
import { getLocale } from '../locales';
|
||||||
|
|
||||||
|
const { localeData, messages } = getLocale();
|
||||||
|
addLocaleData(localeData);
|
||||||
|
|
||||||
|
export default class AdminComponent extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
locale: PropTypes.string.isRequired,
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { locale, children } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
|
{children}
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -7,7 +7,7 @@ import { getLocale } from 'mastodon/locales';
|
||||||
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||||
import MediaGallery from 'mastodon/components/media_gallery';
|
import MediaGallery from 'mastodon/components/media_gallery';
|
||||||
import Poll from 'mastodon/components/poll';
|
import Poll from 'mastodon/components/poll';
|
||||||
import Hashtag from 'mastodon/components/hashtag';
|
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||||
import ModalRoot from 'mastodon/components/modal_root';
|
import ModalRoot from 'mastodon/components/modal_root';
|
||||||
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
||||||
import Video from 'mastodon/features/video';
|
import Video from 'mastodon/features/video';
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||||
import AccountContainer from '../../../containers/account_container';
|
import AccountContainer from '../../../containers/account_container';
|
||||||
import StatusContainer from '../../../containers/status_container';
|
import StatusContainer from '../../../containers/status_container';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Hashtag from '../../../components/hashtag';
|
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import { searchEnabled } from '../../../initial_state';
|
import { searchEnabled } from '../../../initial_state';
|
||||||
import LoadMore from 'mastodon/components/load_more';
|
import LoadMore from 'mastodon/components/load_more';
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Hashtag from 'mastodon/components/hashtag';
|
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export default class Trends extends ImmutablePureComponent {
|
export default class Trends extends ImmutablePureComponent {
|
||||||
|
|
|
@ -69,3 +69,11 @@ export function pluralReady(sourceNumber, division) {
|
||||||
|
|
||||||
return Math.trunc(sourceNumber / closestScale) * closestScale;
|
return Math.trunc(sourceNumber / closestScale) * closestScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} num
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function roundTo10(num) {
|
||||||
|
return Math.round(num * 0.1) / 0.1;
|
||||||
|
}
|
||||||
|
|
|
@ -99,4 +99,24 @@ ready(() => {
|
||||||
|
|
||||||
const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
|
const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
|
||||||
if (registrationMode) onChangeRegistrationMode(registrationMode);
|
if (registrationMode) onChangeRegistrationMode(registrationMode);
|
||||||
|
|
||||||
|
const React = require('react');
|
||||||
|
const ReactDOM = require('react-dom');
|
||||||
|
|
||||||
|
[].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {
|
||||||
|
const componentName = element.getAttribute('data-admin-component');
|
||||||
|
const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props'));
|
||||||
|
|
||||||
|
import('../mastodon/containers/admin_component').then(({ default: AdminComponent }) => {
|
||||||
|
return import('../mastodon/components/admin/' + componentName).then(({ default: Component }) => {
|
||||||
|
ReactDOM.render((
|
||||||
|
<AdminComponent locale={locale}>
|
||||||
|
<Component {...componentProps} />
|
||||||
|
</AdminComponent>
|
||||||
|
), element);
|
||||||
|
});
|
||||||
|
}).catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
@use "sass:math";
|
||||||
|
|
||||||
$no-columns-breakpoint: 600px;
|
$no-columns-breakpoint: 600px;
|
||||||
$sidebar-width: 240px;
|
$sidebar-width: 240px;
|
||||||
$content-width: 840px;
|
$content-width: 840px;
|
||||||
|
@ -909,10 +911,197 @@ a.name-tag,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard__counters.admin-account-counters {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.account-badges {
|
.account-badges {
|
||||||
margin: -2px 0;
|
margin: -2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard__counters.admin-account-counters {
|
.retention {
|
||||||
margin-top: 10px;
|
&__table {
|
||||||
|
&__number {
|
||||||
|
color: $secondary-text-color;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 10px 0;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 120px;
|
||||||
|
|
||||||
|
&.retention__table__average {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__size {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: $darker-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__box {
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: $ui-highlight-color;
|
||||||
|
padding: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $primary-text-color;
|
||||||
|
width: 52px;
|
||||||
|
margin: 1px;
|
||||||
|
|
||||||
|
@for $i from 0 through 10 {
|
||||||
|
&--#{10 * $i} {
|
||||||
|
background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
background: lighten($ui-base-color, 4%);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 55px + 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
display: flex;
|
||||||
|
line-height: 33px;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
display: block;
|
||||||
|
margin-right: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 28px;
|
||||||
|
color: $primary-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__change {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 18px;
|
||||||
|
color: $darker-text-color;
|
||||||
|
margin-bottom: -3px;
|
||||||
|
|
||||||
|
&.positive {
|
||||||
|
color: $valid-value-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.negative {
|
||||||
|
color: $error-value-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
padding: 0 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $darker-text-color;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__graph {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
path:first-child {
|
||||||
|
fill: rgba($highlight-text-color, 0.25) !important;
|
||||||
|
fill-opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
path:last-child {
|
||||||
|
stroke: lighten($highlight-text-color, 6%) !important;
|
||||||
|
fill: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sparkline {
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
background: lighten($ui-base-color, 6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background-color: lighten($ui-base-color, 8%);
|
||||||
|
background-image: linear-gradient(90deg, lighten($ui-base-color, 8%), lighten($ui-base-color, 12%), lighten($ui-base-color, 8%));
|
||||||
|
background-size: 200px 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 1;
|
||||||
|
width: 100%;
|
||||||
|
animation: skeleton 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton {
|
||||||
|
0% {
|
||||||
|
background-position: -200px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: calc(200px + 100%) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dimension {
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
border-bottom: 1px solid lighten($ui-base-color, 4%);
|
||||||
|
|
||||||
|
&__key {
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 11px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
text-align: right;
|
||||||
|
color: $darker-text-color;
|
||||||
|
padding: 11px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $ui-highlight-color;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
@for $i from 0 through 10 {
|
||||||
|
&--#{10 * $i} {
|
||||||
|
background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6955,7 +6955,6 @@ noscript {
|
||||||
&__current {
|
&__current {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
line-height: 36px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding-right: 15px;
|
padding-right: 15px;
|
||||||
|
@ -6977,6 +6976,58 @@ noscript {
|
||||||
fill: none !important;
|
fill: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--requires-review {
|
||||||
|
.trends__item__name {
|
||||||
|
color: $gold-star;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $gold-star;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends__item__current {
|
||||||
|
color: $gold-star;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends__item__sparkline {
|
||||||
|
path:first-child {
|
||||||
|
fill: rgba($gold-star, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
path:last-child {
|
||||||
|
stroke: lighten($gold-star, 6%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
.trends__item__name {
|
||||||
|
color: lighten($ui-base-color, 12%);
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: lighten($ui-base-color, 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends__item__current {
|
||||||
|
color: lighten($ui-base-color, 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends__item__sparkline {
|
||||||
|
path:first-child {
|
||||||
|
fill: rgba(lighten($ui-base-color, 12%), 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
path:last-child {
|
||||||
|
stroke: lighten(lighten($ui-base-color, 12%), 6%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--compact &__item {
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -56,23 +56,56 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard__widgets {
|
.dashboard {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||||
margin: 0 -5px;
|
grid-gap: 10px;
|
||||||
|
|
||||||
& > div {
|
&__item {
|
||||||
flex: 0 0 33.333%;
|
&--span-double-column {
|
||||||
margin-bottom: 20px;
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
& > div {
|
&--span-double-row {
|
||||||
padding: 0 5px;
|
grid-row: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a:not(.name-tag) {
|
&__quick-access {
|
||||||
color: $ui-secondary-color;
|
display: flex;
|
||||||
font-weight: 500;
|
align-items: baseline;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: $ui-highlight-color;
|
||||||
|
color: $primary-text-color;
|
||||||
|
transition: all 100ms ease-in;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 16px;
|
||||||
|
line-height: 36px;
|
||||||
|
height: 36px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
&:active,
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
background-color: lighten($ui-highlight-color, 10%);
|
||||||
|
transition: all 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,29 +1,73 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityTracker
|
class ActivityTracker
|
||||||
|
include Redisable
|
||||||
|
|
||||||
EXPIRE_AFTER = 6.months.seconds
|
EXPIRE_AFTER = 6.months.seconds
|
||||||
|
|
||||||
|
def initialize(prefix, type)
|
||||||
|
@prefix = prefix
|
||||||
|
@type = type
|
||||||
|
end
|
||||||
|
|
||||||
|
def add(value = 1, at_time = Time.now.utc)
|
||||||
|
key = key_at(at_time)
|
||||||
|
|
||||||
|
case @type
|
||||||
|
when :basic
|
||||||
|
redis.incrby(key, value)
|
||||||
|
when :unique
|
||||||
|
redis.pfadd(key, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
redis.expire(key, EXPIRE_AFTER)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(start_at, end_at = Time.now.utc)
|
||||||
|
(start_at.to_date...end_at.to_date).map do |date|
|
||||||
|
key = key_at(date.to_time(:utc))
|
||||||
|
|
||||||
|
value = begin
|
||||||
|
case @type
|
||||||
|
when :basic
|
||||||
|
redis.get(key).to_i
|
||||||
|
when :unique
|
||||||
|
redis.pfcount(key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
[date, value]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sum(start_at, end_at = Time.now.utc)
|
||||||
|
keys = (start_at.to_date...end_at.to_date).flat_map { |date| [key_at(date.to_time(:utc)), legacy_key_at(date)] }.uniq
|
||||||
|
|
||||||
|
case @type
|
||||||
|
when :basic
|
||||||
|
redis.mget(*keys).map(&:to_i).sum
|
||||||
|
when :unique
|
||||||
|
redis.pfcount(*keys)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
include Redisable
|
|
||||||
|
|
||||||
def increment(prefix)
|
def increment(prefix)
|
||||||
key = [prefix, current_week].join(':')
|
new(prefix, :basic).add
|
||||||
|
|
||||||
redis.incrby(key, 1)
|
|
||||||
redis.expire(key, EXPIRE_AFTER)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def record(prefix, value)
|
def record(prefix, value)
|
||||||
key = [prefix, current_week].join(':')
|
new(prefix, :unique).add(value)
|
||||||
|
|
||||||
redis.pfadd(key, value)
|
|
||||||
redis.expire(key, EXPIRE_AFTER)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def current_week
|
|
||||||
Time.zone.today.cweek
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def key_at(at_time)
|
||||||
|
"#{@prefix}:#{at_time.beginning_of_day.to_i}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def legacy_key_at(at_time)
|
||||||
|
"#{@prefix}:#{at_time.to_date.cweek}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Dimension
|
||||||
|
DIMENSIONS = {
|
||||||
|
languages: Admin::Metrics::Dimension::LanguagesDimension,
|
||||||
|
sources: Admin::Metrics::Dimension::SourcesDimension,
|
||||||
|
servers: Admin::Metrics::Dimension::ServersDimension,
|
||||||
|
space_usage: Admin::Metrics::Dimension::SpaceUsageDimension,
|
||||||
|
software_versions: Admin::Metrics::Dimension::SoftwareVersionsDimension,
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def self.retrieve(dimension_keys, start_at, end_at, limit)
|
||||||
|
Array(dimension_keys).map { |key| DIMENSIONS[key.to_sym]&.new(start_at, end_at, limit) }.compact
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Dimension::BaseDimension
|
||||||
|
def initialize(start_at, end_at, limit)
|
||||||
|
@start_at = start_at&.to_datetime
|
||||||
|
@end_at = end_at&.to_datetime
|
||||||
|
@limit = limit&.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def key
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def data
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.model_name
|
||||||
|
self.class.name
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_attribute_for_serialization(key)
|
||||||
|
send(key) if respond_to?(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def time_period
|
||||||
|
(@start_at...@end_at)
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension::BaseDimension
|
||||||
|
def key
|
||||||
|
'languages'
|
||||||
|
end
|
||||||
|
|
||||||
|
def data
|
||||||
|
sql = <<-SQL.squish
|
||||||
|
SELECT locale, count(*) AS value
|
||||||
|
FROM users
|
||||||
|
WHERE current_sign_in_at BETWEEN $1 AND $2
|
||||||
|
AND locale IS NOT NULL
|
||||||
|
GROUP BY locale
|
||||||
|
ORDER BY count(*) DESC
|
||||||
|
LIMIT $3
|
||||||
|
SQL
|
||||||
|
|
||||||
|
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])
|
||||||
|
|
||||||
|
rows.map { |row| { key: row['locale'], human_key: SettingsHelper::HUMAN_LOCALES[row['locale'].to_sym], value: row['value'].to_s } }
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::BaseDimension
|
||||||
|
def key
|
||||||
|
'servers'
|
||||||
|
end
|
||||||
|
|
||||||
|
def data
|
||||||
|
sql = <<-SQL.squish
|
||||||
|
SELECT accounts.domain, count(*) AS value
|
||||||
|
FROM statuses
|
||||||
|
INNER JOIN accounts ON accounts.id = statuses.account_id
|
||||||
|
WHERE statuses.id BETWEEN $1 AND $2
|
||||||
|
GROUP BY accounts.domain
|
||||||
|
ORDER BY count(*) DESC
|
||||||
|
LIMIT $3
|
||||||
|
SQL
|
||||||
|
|
||||||
|
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, Mastodon::Snowflake.id_at(@start_at)], [nil, Mastodon::Snowflake.id_at(@end_at)], [nil, @limit]])
|
||||||
|
|
||||||
|
rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,69 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dimension::BaseDimension
|
||||||
|
include Redisable
|
||||||
|
|
||||||
|
def key
|
||||||
|
'software_versions'
|
||||||
|
end
|
||||||
|
|
||||||
|
def data
|
||||||
|
[mastodon_version, ruby_version, postgresql_version, redis_version]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def mastodon_version
|
||||||
|
value = Mastodon::Version.to_s
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'mastodon',
|
||||||
|
human_key: 'Mastodon',
|
||||||
|
value: value,
|
||||||
|
human_value: value,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def ruby_version
|
||||||
|
value = "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'ruby',
|
||||||
|
human_key: 'Ruby',
|
||||||
|
value: value,
|
||||||
|
human_value: value,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def postgresql_version
|
||||||
|
value = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1]
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'postgresql',
|
||||||
|
human_key: 'PostgreSQL',
|
||||||
|
value: value,
|
||||||
|
human_value: value,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis_version
|
||||||
|
value = redis_info['redis_version']
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'redis',
|
||||||
|
human_key: 'Redis',
|
||||||
|
value: value,
|
||||||
|
human_value: value,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis_info
|
||||||
|
@redis_info ||= begin
|
||||||
|
if redis.is_a?(Redis::Namespace)
|
||||||
|
redis.redis.info
|
||||||
|
else
|
||||||
|
redis.info
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Dimension::SourcesDimension < Admin::Metrics::Dimension::BaseDimension
|
||||||
|
def key
|
||||||
|
'sources'
|
||||||
|
end
|
||||||
|
|
||||||
|
def data
|
||||||
|
sql = <<-SQL.squish
|
||||||
|
SELECT oauth_applications.name, count(*) AS value
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN oauth_applications ON oauth_applications.id = users.created_by_application_id
|
||||||
|
WHERE users.created_at BETWEEN $1 AND $2
|
||||||
|
GROUP BY oauth_applications.name
|
||||||
|
ORDER BY count(*) DESC
|
||||||
|
LIMIT $3
|
||||||
|
SQL
|
||||||
|
|
||||||
|
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])
|
||||||
|
|
||||||
|
rows.map { |row| { key: row['name'] || 'web', human_key: row['name'] || I18n.t('admin.dashboard.website'), value: row['value'].to_s } }
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,70 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Dimension::SpaceUsageDimension < Admin::Metrics::Dimension::BaseDimension
|
||||||
|
include Redisable
|
||||||
|
include ActionView::Helpers::NumberHelper
|
||||||
|
|
||||||
|
def key
|
||||||
|
'space_usage'
|
||||||
|
end
|
||||||
|
|
||||||
|
def data
|
||||||
|
[postgresql_size, redis_size, media_size]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def postgresql_size
|
||||||
|
value = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size']
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'postgresql',
|
||||||
|
human_key: 'PostgreSQL',
|
||||||
|
value: value.to_s,
|
||||||
|
unit: 'bytes',
|
||||||
|
human_value: number_to_human_size(value),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis_size
|
||||||
|
value = redis_info['used_memory']
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'redis',
|
||||||
|
human_key: 'Redis',
|
||||||
|
value: value.to_s,
|
||||||
|
unit: 'bytes',
|
||||||
|
human_value: number_to_human_size(value),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def media_size
|
||||||
|
value = [
|
||||||
|
MediaAttachment.sum(Arel.sql('COALESCE(file_file_size, 0) + COALESCE(thumbnail_file_size, 0)')),
|
||||||
|
CustomEmoji.sum(:image_file_size),
|
||||||
|
PreviewCard.sum(:image_file_size),
|
||||||
|
Account.sum(Arel.sql('COALESCE(avatar_file_size, 0) + COALESCE(header_file_size, 0)')),
|
||||||
|
Backup.sum(:dump_file_size),
|
||||||
|
Import.sum(:data_file_size),
|
||||||
|
SiteUpload.sum(:file_file_size),
|
||||||
|
].sum
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'media',
|
||||||
|
human_key: I18n.t('admin.dashboard.media_storage'),
|
||||||
|
value: value.to_s,
|
||||||
|
unit: 'bytes',
|
||||||
|
human_value: number_to_human_size(value),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis_info
|
||||||
|
@redis_info ||= begin
|
||||||
|
if redis.is_a?(Redis::Namespace)
|
||||||
|
redis.redis.info
|
||||||
|
else
|
||||||
|
redis.info
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,15 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Measure
|
||||||
|
MEASURES = {
|
||||||
|
active_users: Admin::Metrics::Measure::ActiveUsersMeasure,
|
||||||
|
new_users: Admin::Metrics::Measure::NewUsersMeasure,
|
||||||
|
interactions: Admin::Metrics::Measure::InteractionsMeasure,
|
||||||
|
opened_reports: Admin::Metrics::Measure::OpenedReportsMeasure,
|
||||||
|
resolved_reports: Admin::Metrics::Measure::ResolvedReportsMeasure,
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def self.retrieve(measure_keys, start_at, end_at)
|
||||||
|
Array(measure_keys).map { |key| MEASURES[key.to_sym]&.new(start_at, end_at) }.compact
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,33 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Measure::ActiveUsersMeasure < Admin::Metrics::Measure::BaseMeasure
|
||||||
|
def key
|
||||||
|
'active_users'
|
||||||
|
end
|
||||||
|
|
||||||
|
def total
|
||||||
|
activity_tracker.sum(time_period.first, time_period.last)
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_total
|
||||||
|
activity_tracker.sum(previous_time_period.first, previous_time_period.last)
|
||||||
|
end
|
||||||
|
|
||||||
|
def data
|
||||||
|
activity_tracker.get(time_period.first, time_period.last).map { |date, value| { date: date.to_time(:utc).iso8601, value: value.to_s } }
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def activity_tracker
|
||||||
|
@activity_tracker ||= ActivityTracker.new('activity:logins', :unique)
|
||||||
|
end
|
||||||
|
|
||||||
|
def time_period
|
||||||
|
(@start_at.to_date...@end_at.to_date)
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_time_period
|
||||||
|
((@start_at.to_date - length_of_period)...(@end_at.to_date - length_of_period))
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,46 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Measure::BaseMeasure
|
||||||
|
def initialize(start_at, end_at)
|
||||||
|
@start_at = start_at&.to_datetime
|
||||||
|
@end_at = end_at&.to_datetime
|
||||||
|
end
|
||||||
|
|
||||||
|
def key
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def total
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_total
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def data
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.model_name
|
||||||
|
self.class.name
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_attribute_for_serialization(key)
|
||||||
|
send(key) if respond_to?(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def time_period
|
||||||
|
(@start_at...@end_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_time_period
|
||||||
|
((@start_at - length_of_period)...(@end_at - length_of_period))
|
||||||
|
end
|
||||||
|
|
||||||
|
def length_of_period
|
||||||
|
@length_of_period ||= @end_at - @start_at
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,33 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Measure::InteractionsMeasure < Admin::Metrics::Measure::BaseMeasure
|
||||||
|
def key
|
||||||
|
'interactions'
|
||||||
|
end
|
||||||
|
|
||||||
|
def total
|
||||||
|
activity_tracker.sum(time_period.first, time_period.last)
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_total
|
||||||
|
activity_tracker.sum(previous_time_period.first, previous_time_period.last)
|
||||||
|
end
|
||||||
|
|
||||||
|
def data
|
||||||
|
activity_tracker.get(time_period.first, time_period.last).map { |date, value| { date: date.to_time(:utc).iso8601, value: value.to_s } }
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def activity_tracker
|
||||||
|
@activity_tracker ||= ActivityTracker.new('activity:interactions', :basic)
|
||||||
|
end
|
||||||
|
|
||||||
|
def time_period
|
||||||
|
(@start_at.to_date...@end_at.to_date)
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_time_period
|
||||||
|
((@start_at.to_date - length_of_period)...(@end_at.to_date - length_of_period))
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,35 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Measure::NewUsersMeasure < Admin::Metrics::Measure::BaseMeasure
|
||||||
|
def key
|
||||||
|
'new_users'
|
||||||
|
end
|
||||||
|
|
||||||
|
def total
|
||||||
|
User.where(created_at: time_period).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_total
|
||||||
|
User.where(created_at: previous_time_period).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def data
|
||||||
|
sql = <<-SQL.squish
|
||||||
|
SELECT axis.*, (
|
||||||
|
WITH new_users AS (
|
||||||
|
SELECT users.id
|
||||||
|
FROM users
|
||||||
|
WHERE date_trunc('day', users.created_at)::date = axis.period
|
||||||
|
)
|
||||||
|
SELECT count(*) FROM new_users
|
||||||
|
) AS value
|
||||||
|
FROM (
|
||||||
|
SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
|
||||||
|
) AS axis
|
||||||
|
SQL
|
||||||
|
|
||||||
|
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]])
|
||||||
|
|
||||||
|
rows.map { |row| { date: row['period'], value: row['value'].to_s } }
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,35 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Measure::OpenedReportsMeasure < Admin::Metrics::Measure::BaseMeasure
|
||||||
|
def key
|
||||||
|
'opened_reports'
|
||||||
|
end
|
||||||
|
|
||||||
|
def total
|
||||||
|
Report.where(created_at: time_period).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_total
|
||||||
|
Report.where(created_at: previous_time_period).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def data
|
||||||
|
sql = <<-SQL.squish
|
||||||
|
SELECT axis.*, (
|
||||||
|
WITH new_reports AS (
|
||||||
|
SELECT reports.id
|
||||||
|
FROM reports
|
||||||
|
WHERE date_trunc('day', reports.created_at)::date = axis.period
|
||||||
|
)
|
||||||
|
SELECT count(*) FROM new_reports
|
||||||
|
) AS value
|
||||||
|
FROM (
|
||||||
|
SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
|
||||||
|
) AS axis
|
||||||
|
SQL
|
||||||
|
|
||||||
|
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]])
|
||||||
|
|
||||||
|
rows.map { |row| { date: row['period'], value: row['value'].to_s } }
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,36 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure::BaseMeasure
|
||||||
|
def key
|
||||||
|
'resolved_reports'
|
||||||
|
end
|
||||||
|
|
||||||
|
def total
|
||||||
|
Report.resolved.where(updated_at: time_period).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_total
|
||||||
|
Report.resolved.where(updated_at: previous_time_period).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def data
|
||||||
|
sql = <<-SQL.squish
|
||||||
|
SELECT axis.*, (
|
||||||
|
WITH resolved_reports AS (
|
||||||
|
SELECT reports.id
|
||||||
|
FROM reports
|
||||||
|
WHERE action_taken
|
||||||
|
AND date_trunc('day', reports.updated_at)::date = axis.period
|
||||||
|
)
|
||||||
|
SELECT count(*) FROM resolved_reports
|
||||||
|
) AS value
|
||||||
|
FROM (
|
||||||
|
SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
|
||||||
|
) AS axis
|
||||||
|
SQL
|
||||||
|
|
||||||
|
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]])
|
||||||
|
|
||||||
|
rows.map { |row| { date: row['period'], value: row['value'].to_s } }
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,67 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Metrics::Retention
|
||||||
|
class Cohort < ActiveModelSerializers::Model
|
||||||
|
attributes :period, :frequency, :data
|
||||||
|
end
|
||||||
|
|
||||||
|
class CohortData < ActiveModelSerializers::Model
|
||||||
|
attributes :date, :percent, :value
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(start_at, end_at, frequency)
|
||||||
|
@start_at = start_at&.to_date
|
||||||
|
@end_at = end_at&.to_date
|
||||||
|
@frequency = %w(day month).include?(frequency) ? frequency : 'day'
|
||||||
|
end
|
||||||
|
|
||||||
|
def cohorts
|
||||||
|
sql = <<-SQL.squish
|
||||||
|
SELECT axis.*, (
|
||||||
|
WITH new_users AS (
|
||||||
|
SELECT users.id
|
||||||
|
FROM users
|
||||||
|
WHERE date_trunc($3, users.created_at)::date = axis.cohort_period
|
||||||
|
),
|
||||||
|
retained_users AS (
|
||||||
|
SELECT users.id
|
||||||
|
FROM users
|
||||||
|
INNER JOIN new_users on new_users.id = users.id
|
||||||
|
WHERE date_trunc($3, users.current_sign_in_at) >= axis.retention_period
|
||||||
|
)
|
||||||
|
SELECT ARRAY[count(*), (count(*) + 1)::float / (SELECT count(*) + 1 FROM new_users)] AS retention_value_and_percent
|
||||||
|
FROM retained_users
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
WITH cohort_periods AS (
|
||||||
|
SELECT generate_series(date_trunc($3, $1::timestamp)::date, date_trunc($3, $2::timestamp)::date, ('1 ' || $3)::interval) AS cohort_period
|
||||||
|
),
|
||||||
|
retention_periods AS (
|
||||||
|
SELECT cohort_period AS retention_period FROM cohort_periods
|
||||||
|
)
|
||||||
|
SELECT *
|
||||||
|
FROM cohort_periods, retention_periods
|
||||||
|
WHERE retention_period >= cohort_period
|
||||||
|
) as axis
|
||||||
|
SQL
|
||||||
|
|
||||||
|
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @frequency]])
|
||||||
|
|
||||||
|
rows.each_with_object([]) do |row, arr|
|
||||||
|
current_cohort = arr.last
|
||||||
|
|
||||||
|
if current_cohort.nil? || current_cohort.period != row['cohort_period']
|
||||||
|
current_cohort = Cohort.new(period: row['cohort_period'], frequency: @frequency, data: [])
|
||||||
|
arr << current_cohort
|
||||||
|
end
|
||||||
|
|
||||||
|
value, percent = row['retention_value_and_percent'].delete('{}').split(',')
|
||||||
|
|
||||||
|
current_cohort.data << CohortData.new(
|
||||||
|
date: row['retention_period'],
|
||||||
|
percent: percent.to_f,
|
||||||
|
value: value.to_s
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -24,8 +24,8 @@ class InstancePresenter
|
||||||
Rails.cache.fetch('user_count') { User.confirmed.joins(:account).merge(Account.without_suspended).count }
|
Rails.cache.fetch('user_count') { User.confirmed.joins(:account).merge(Account.without_suspended).count }
|
||||||
end
|
end
|
||||||
|
|
||||||
def active_user_count(weeks = 4)
|
def active_user_count(num_weeks = 4)
|
||||||
Rails.cache.fetch("active_user_count/#{weeks}") { Redis.current.pfcount(*(0...weeks).map { |i| "activity:logins:#{i.weeks.ago.utc.to_date.cweek}" }) }
|
Rails.cache.fetch("active_user_count/#{num_weeks}") { ActivityTracker.new('activity:logins', :unique).sum(num_weeks.weeks.ago) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_count
|
def status_count
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class REST::Admin::CohortSerializer < ActiveModel::Serializer
|
||||||
|
attributes :period, :frequency
|
||||||
|
|
||||||
|
class CohortDataSerializer < ActiveModel::Serializer
|
||||||
|
attributes :date, :percent, :value
|
||||||
|
|
||||||
|
def date
|
||||||
|
object.date.iso8601
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
has_many :data, serializer: CohortDataSerializer
|
||||||
|
|
||||||
|
def period
|
||||||
|
object.period.iso8601
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,5 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class REST::Admin::DimensionSerializer < ActiveModel::Serializer
|
||||||
|
attributes :key, :data
|
||||||
|
end
|
|
@ -0,0 +1,13 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class REST::Admin::MeasureSerializer < ActiveModel::Serializer
|
||||||
|
attributes :key, :total, :previous_total, :data
|
||||||
|
|
||||||
|
def total
|
||||||
|
object.total.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_total
|
||||||
|
object.previous_total.to_s
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,13 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class REST::Admin::TagSerializer < REST::TagSerializer
|
||||||
|
attributes :id, :trendable, :usable, :requires_review
|
||||||
|
|
||||||
|
def id
|
||||||
|
object.id.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def requires_review
|
||||||
|
object.requires_review?
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,6 +1,14 @@
|
||||||
|
- content_for :header_tags do
|
||||||
|
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
|
||||||
|
|
||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('admin.dashboard.title')
|
= t('admin.dashboard.title')
|
||||||
|
|
||||||
|
- content_for :heading_actions do
|
||||||
|
= l(@time_period.first)
|
||||||
|
= ' - '
|
||||||
|
= l(@time_period.last)
|
||||||
|
|
||||||
- unless @system_checks.empty?
|
- unless @system_checks.empty?
|
||||||
.flash-message-stack
|
.flash-message-stack
|
||||||
- @system_checks.each do |message|
|
- @system_checks.each do |message|
|
||||||
|
@ -9,131 +17,52 @@
|
||||||
- if message.action
|
- if message.action
|
||||||
= link_to t("admin.system_checks.#{message.key}.action"), message.action
|
= link_to t("admin.system_checks.#{message.key}.action"), message.action
|
||||||
|
|
||||||
.dashboard__counters
|
.dashboard
|
||||||
%div
|
.dashboard__item
|
||||||
= link_to admin_accounts_url(local: 1, recent: 1) do
|
= react_admin_component :counter, measure: 'new_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.new_users'), href: admin_accounts_path
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@users_count, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @users_count
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.total_users'
|
|
||||||
%div
|
|
||||||
%div
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@registrations_week, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @registrations_week
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.week_users_new'
|
|
||||||
%div
|
|
||||||
%div
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@logins_week, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @logins_week
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.week_users_active'
|
|
||||||
%div
|
|
||||||
= link_to admin_pending_accounts_path do
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@pending_users_count, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @pending_users_count
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.pending_users'
|
|
||||||
%div
|
|
||||||
= link_to admin_reports_url do
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@reports_count, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @reports_count
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.open_reports'
|
|
||||||
%div
|
|
||||||
= link_to admin_tags_path(pending_review: '1') do
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@pending_tags_count, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @pending_tags_count
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.pending_tags'
|
|
||||||
%div
|
|
||||||
%div
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@interactions_week, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @interactions_week
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.week_interactions'
|
|
||||||
%div
|
|
||||||
= link_to sidekiq_url do
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@queue_backlog, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @queue_backlog
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.backlog'
|
|
||||||
|
|
||||||
.dashboard__widgets
|
.dashboard__item
|
||||||
.dashboard__widgets__users
|
= react_admin_component :counter, measure: 'active_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.active_users'), href: admin_accounts_path
|
||||||
%div
|
|
||||||
%h4= t 'admin.dashboard.recent_users'
|
|
||||||
%ul
|
|
||||||
- @recent_users.each do |user|
|
|
||||||
%li= admin_account_link_to(user.account)
|
|
||||||
|
|
||||||
.dashboard__widgets__features
|
.dashboard__item
|
||||||
%div
|
= react_admin_component :counter, measure: 'interactions', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.interactions')
|
||||||
%h4= t 'admin.dashboard.features'
|
|
||||||
%ul
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.feature_registrations'), edit_admin_settings_path), @registrations_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.feature_invites'), edit_admin_settings_path), @invites_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.feature_deletions'), edit_admin_settings_path), @deletions_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.feature_profile_directory'), edit_admin_settings_path), @profile_directory)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.feature_timeline_preview'), edit_admin_settings_path), @timeline_preview)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.trends'), edit_admin_settings_path), @trends_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled)
|
|
||||||
|
|
||||||
.dashboard__widgets__versions
|
.dashboard__item
|
||||||
%div
|
= react_admin_component :counter, measure: 'opened_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.opened_reports'), href: admin_reports_path
|
||||||
%h4= t 'admin.dashboard.software'
|
|
||||||
%ul
|
|
||||||
%li
|
|
||||||
Mastodon
|
|
||||||
%span.pull-right= @version
|
|
||||||
%li
|
|
||||||
Ruby
|
|
||||||
%span.pull-right= "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
|
|
||||||
%li
|
|
||||||
PostgreSQL
|
|
||||||
%span.pull-right= @database_version
|
|
||||||
%li
|
|
||||||
Redis
|
|
||||||
%span.pull-right= @redis_version
|
|
||||||
|
|
||||||
.dashboard__widgets__space
|
.dashboard__item
|
||||||
%div
|
= react_admin_component :counter, measure: 'resolved_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.resolved_reports'), href: admin_reports_path(resolved: '1')
|
||||||
%h4= t 'admin.dashboard.space'
|
|
||||||
%ul
|
|
||||||
%li
|
|
||||||
PostgreSQL
|
|
||||||
%span.pull-right= number_to_human_size @database_size
|
|
||||||
%li
|
|
||||||
Redis
|
|
||||||
%span.pull-right= number_to_human_size @redis_size
|
|
||||||
|
|
||||||
.dashboard__widgets__config
|
.dashboard__item
|
||||||
%div
|
= link_to admin_reports_path, class: 'dashboard__quick-access' do
|
||||||
%h4= t 'admin.dashboard.config'
|
%span= t('admin.dashboard.pending_reports_html', count: @pending_reports_count)
|
||||||
%ul
|
= fa_icon 'chevron-right fw'
|
||||||
%li
|
|
||||||
= feature_hint(t('admin.dashboard.search'), @search_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint(t('admin.dashboard.single_user_mode'), @single_user_mode)
|
|
||||||
%li
|
|
||||||
= feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch)
|
|
||||||
%li
|
|
||||||
= feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint('LDAP', @ldap_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint('CAS', @cas_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint('SAML', @saml_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint('PAM', @pam_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint(t('admin.dashboard.hidden_service'), @hidden_service)
|
|
||||||
|
|
||||||
.dashboard__widgets__trends
|
= link_to admin_pending_accounts_path, class: 'dashboard__quick-access' do
|
||||||
%div
|
%span= t('admin.dashboard.pending_users_html', count: @pending_users_count)
|
||||||
%h4= t 'admin.dashboard.trends'
|
= fa_icon 'chevron-right fw'
|
||||||
%ul
|
|
||||||
- @trending_hashtags.each do |tag|
|
= link_to admin_tags_path(pending_review: '1'), class: 'dashboard__quick-access' do
|
||||||
%li
|
%span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count)
|
||||||
= link_to content_tag(:span, "##{tag.name}", class: !tag.trendable? && !tag.reviewed? ? 'warning-hint' : (!tag.trendable? ? 'negative-hint' : nil)), admin_tag_path(tag.id)
|
= fa_icon 'chevron-right fw'
|
||||||
%span.pull-right= number_with_delimiter(tag.history[0][:accounts].to_i)
|
|
||||||
|
.dashboard__item
|
||||||
|
= react_admin_component :dimension, dimension: 'sources', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.sources')
|
||||||
|
|
||||||
|
.dashboard__item
|
||||||
|
= react_admin_component :dimension, dimension: 'languages', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_languages')
|
||||||
|
|
||||||
|
.dashboard__item
|
||||||
|
= react_admin_component :dimension, dimension: 'servers', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_servers')
|
||||||
|
|
||||||
|
.dashboard__item.dashboard__item--span-double-column
|
||||||
|
= react_admin_component :retention, start_at: @time_period.last - 6.months, end_at: @time_period.last, frequency: 'month'
|
||||||
|
|
||||||
|
.dashboard__item.dashboard__item--span-double-row
|
||||||
|
= react_admin_component :trends, limit: 7
|
||||||
|
|
||||||
|
.dashboard__item
|
||||||
|
= react_admin_component :dimension, dimension: 'software_versions', start_at: @time_period.first, end_at: @time_period.last, limit: 4, label: t('admin.dashboard.software')
|
||||||
|
|
||||||
|
.dashboard__item
|
||||||
|
= react_admin_component :dimension, dimension: 'space_usage', start_at: @time_period.first, end_at: @time_period.last, limit: 3, label: t('admin.dashboard.space')
|
||||||
|
|
|
@ -371,32 +371,28 @@ en:
|
||||||
updated_msg: Emoji successfully updated!
|
updated_msg: Emoji successfully updated!
|
||||||
upload: Upload
|
upload: Upload
|
||||||
dashboard:
|
dashboard:
|
||||||
authorized_fetch_mode: Secure mode
|
active_users: active users
|
||||||
backlog: backlogged jobs
|
interactions: interactions
|
||||||
config: Configuration
|
media_storage: Media storage
|
||||||
feature_deletions: Account deletions
|
new_users: new users
|
||||||
feature_invites: Invite links
|
opened_reports: reports opened
|
||||||
feature_profile_directory: Profile directory
|
pending_reports_html:
|
||||||
feature_registrations: Registrations
|
one: "<strong>1</strong> pending reports"
|
||||||
feature_relay: Federation relay
|
other: "<strong>%{count}</strong> pending reports"
|
||||||
feature_timeline_preview: Timeline preview
|
pending_tags_html:
|
||||||
features: Features
|
one: "<strong>1</strong> pending hashtags"
|
||||||
hidden_service: Federation with hidden services
|
other: "<strong>%{count}</strong> pending hashtags"
|
||||||
open_reports: open reports
|
pending_users_html:
|
||||||
pending_tags: hashtags waiting for review
|
one: "<strong>1</strong> pending users"
|
||||||
pending_users: users waiting for review
|
other: "<strong>%{count}</strong> pending users"
|
||||||
recent_users: Recent users
|
resolved_reports: reports resolved
|
||||||
search: Full-text search
|
|
||||||
single_user_mode: Single user mode
|
|
||||||
software: Software
|
software: Software
|
||||||
|
sources: Sign-up sources
|
||||||
space: Space usage
|
space: Space usage
|
||||||
title: Dashboard
|
title: Dashboard
|
||||||
total_users: users in total
|
top_languages: Top active languages
|
||||||
trends: Trends
|
top_servers: Top active servers
|
||||||
week_interactions: interactions this week
|
website: Website
|
||||||
week_users_active: active this week
|
|
||||||
week_users_new: users this week
|
|
||||||
whitelist_mode: Limited federation mode
|
|
||||||
domain_allows:
|
domain_allows:
|
||||||
add_new: Allow federation with domain
|
add_new: Allow federation with domain
|
||||||
created_msg: Domain has been successfully allowed for federation
|
created_msg: Domain has been successfully allowed for federation
|
||||||
|
|
|
@ -510,6 +510,12 @@ Rails.application.routes.draw do
|
||||||
post :resolve
|
post :resolve
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :trends, only: [:index]
|
||||||
|
|
||||||
|
post :measures, to: 'measures#create'
|
||||||
|
post :dimensions, to: 'dimensions#create'
|
||||||
|
post :retention, to: 'retention#create'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue