Introduce per-instance chart (#4183)
* Introduce per-instance chart * Implement chart view in client * Handle note deleting * More chart srcs * Add drive stats * Improve drive stats * Fix bug * Add icon
This commit is contained in:
		@@ -40,6 +40,29 @@
 | 
			
		||||
					<span>{{ $t('latest-request-received-at') }}</span>
 | 
			
		||||
				</ui-input>
 | 
			
		||||
				<ui-switch v-model="instance.isBlocked" @change="updateInstance()">{{ $t('block') }}</ui-switch>
 | 
			
		||||
				<details>
 | 
			
		||||
					<summary>{{ $t('charts') }}</summary>
 | 
			
		||||
					<ui-horizon-group inputs>
 | 
			
		||||
						<ui-select v-model="chartSrc">
 | 
			
		||||
							<option value="requests">{{ $t('chart-srcs.requests') }}</option>
 | 
			
		||||
							<option value="users">{{ $t('chart-srcs.users') }}</option>
 | 
			
		||||
							<option value="users-total">{{ $t('chart-srcs.users-total') }}</option>
 | 
			
		||||
							<option value="notes">{{ $t('chart-srcs.notes') }}</option>
 | 
			
		||||
							<option value="notes-total">{{ $t('chart-srcs.notes-total') }}</option>
 | 
			
		||||
							<option value="ff">{{ $t('chart-srcs.ff') }}</option>
 | 
			
		||||
							<option value="ff-total">{{ $t('chart-srcs.ff-total') }}</option>
 | 
			
		||||
							<option value="drive-usage">{{ $t('chart-srcs.drive-usage') }}</option>
 | 
			
		||||
							<option value="drive-usage-total">{{ $t('chart-srcs.drive-usage-total') }}</option>
 | 
			
		||||
							<option value="drive-files">{{ $t('chart-srcs.drive-files') }}</option>
 | 
			
		||||
							<option value="drive-files-total">{{ $t('chart-srcs.drive-files-total') }}</option>
 | 
			
		||||
						</ui-select>
 | 
			
		||||
						<ui-select v-model="chartSpan">
 | 
			
		||||
							<option value="hour">{{ $t('chart-spans.hour') }}</option>
 | 
			
		||||
							<option value="day">{{ $t('chart-spans.day') }}</option>
 | 
			
		||||
						</ui-select>
 | 
			
		||||
					</ui-horizon-group>
 | 
			
		||||
					<div ref="chart"></div>
 | 
			
		||||
				</details>
 | 
			
		||||
				<details>
 | 
			
		||||
					<summary>{{ $t('remove-all-following') }}</summary>
 | 
			
		||||
					<ui-button @click="removeAllFollowing()" style="margin-top: 16px;"><fa :icon="faMinusCircle"/> {{ $t('remove-all-following') }}</ui-button>
 | 
			
		||||
@@ -50,7 +73,7 @@
 | 
			
		||||
	</ui-card>
 | 
			
		||||
 | 
			
		||||
	<ui-card>
 | 
			
		||||
		<div slot="title"><fa :icon="faUsers"/> {{ $t('instances') }}</div>
 | 
			
		||||
		<div slot="title"><fa :icon="faServer"/> {{ $t('instances') }}</div>
 | 
			
		||||
		<section class="fit-top">
 | 
			
		||||
			<ui-horizon-group inputs>
 | 
			
		||||
				<ui-select v-model="sort">
 | 
			
		||||
@@ -65,6 +88,10 @@
 | 
			
		||||
					<option value="+following">{{ $t('sorts.followingDesc') }}</option>
 | 
			
		||||
					<option value="-followers">{{ $t('sorts.followersAsc') }}</option>
 | 
			
		||||
					<option value="+followers">{{ $t('sorts.followersDesc') }}</option>
 | 
			
		||||
					<option value="-driveUsage">{{ $t('sorts.driveUsageAsc') }}</option>
 | 
			
		||||
					<option value="+driveUsage">{{ $t('sorts.driveUsageDesc') }}</option>
 | 
			
		||||
					<option value="-driveFiles">{{ $t('sorts.driveFilesAsc') }}</option>
 | 
			
		||||
					<option value="+driveFiles">{{ $t('sorts.driveFilesDesc') }}</option>
 | 
			
		||||
				</ui-select>
 | 
			
		||||
				<ui-select v-model="state">
 | 
			
		||||
					<span slot="label">{{ $t('state') }}</span>
 | 
			
		||||
@@ -101,7 +128,13 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
import i18n from '../../i18n';
 | 
			
		||||
import { faGlobe, faTerminal, faSearch, faMinusCircle } from '@fortawesome/free-solid-svg-icons';
 | 
			
		||||
import { faGlobe, faTerminal, faSearch, faMinusCircle, faServer } from '@fortawesome/free-solid-svg-icons';
 | 
			
		||||
import ApexCharts from 'apexcharts';
 | 
			
		||||
import * as tinycolor from 'tinycolor2';
 | 
			
		||||
 | 
			
		||||
const chartLimit = 90;
 | 
			
		||||
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
 | 
			
		||||
const negate = arr => arr.map(x => -x);
 | 
			
		||||
 | 
			
		||||
export default Vue.extend({
 | 
			
		||||
	i18n: i18n('admin/views/federation.vue'),
 | 
			
		||||
@@ -114,10 +147,42 @@ export default Vue.extend({
 | 
			
		||||
			state: 'all',
 | 
			
		||||
			limit: 50,
 | 
			
		||||
			instances: [],
 | 
			
		||||
			faGlobe, faTerminal, faSearch, faMinusCircle
 | 
			
		||||
			chart: null,
 | 
			
		||||
			chartSrc: 'requests',
 | 
			
		||||
			chartSpan: 'hour',
 | 
			
		||||
			chartInstance: null,
 | 
			
		||||
			faGlobe, faTerminal, faSearch, faMinusCircle, faServer
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		data(): any {
 | 
			
		||||
			if (this.chart == null) return null;
 | 
			
		||||
			switch (this.chartSrc) {
 | 
			
		||||
				case 'requests': return this.requestsChart();
 | 
			
		||||
				case 'users': return this.usersChart(false);
 | 
			
		||||
				case 'users-total': return this.usersChart(true);
 | 
			
		||||
				case 'notes': return this.notesChart(false);
 | 
			
		||||
				case 'notes-total': return this.notesChart(true);
 | 
			
		||||
				case 'ff': return this.ffChart(false);
 | 
			
		||||
				case 'ff-total': return this.ffChart(true);
 | 
			
		||||
				case 'drive-usage': return this.driveUsageChart(false);
 | 
			
		||||
				case 'drive-usage-total': return this.driveUsageChart(true);
 | 
			
		||||
				case 'drive-files': return this.driveFilesChart(false);
 | 
			
		||||
				case 'drive-files-total': return this.driveFilesChart(true);
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		stats(): any[] {
 | 
			
		||||
			const stats =
 | 
			
		||||
				this.chartSpan == 'day' ? this.chart.perDay :
 | 
			
		||||
				this.chartSpan == 'hour' ? this.chart.perHour :
 | 
			
		||||
				null;
 | 
			
		||||
 | 
			
		||||
			return stats;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		sort() {
 | 
			
		||||
			this.fetchInstances();
 | 
			
		||||
@@ -126,12 +191,42 @@ export default Vue.extend({
 | 
			
		||||
		state() {
 | 
			
		||||
			this.fetchInstances();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async instance() {
 | 
			
		||||
			this.now = new Date();
 | 
			
		||||
 | 
			
		||||
			const [perHour, perDay] = await Promise.all([
 | 
			
		||||
				this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }),
 | 
			
		||||
				this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }),
 | 
			
		||||
			]);
 | 
			
		||||
 | 
			
		||||
			const chart = {
 | 
			
		||||
				perHour: perHour,
 | 
			
		||||
				perDay: perDay
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			this.chart = chart;
 | 
			
		||||
 | 
			
		||||
			this.renderChart();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		chartSrc() {
 | 
			
		||||
			this.renderChart();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		chartSpan() {
 | 
			
		||||
			this.renderChart();
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.fetchInstances();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	beforeDestroy() {
 | 
			
		||||
		this.chartInstance.destroy();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		showInstance() {
 | 
			
		||||
			this.$root.api('federation/show-instance', {
 | 
			
		||||
@@ -177,6 +272,180 @@ export default Vue.extend({
 | 
			
		||||
				isBlocked: this.instance.isBlocked,
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		setSrc(src) {
 | 
			
		||||
			this.chartSrc = src;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		renderChart() {
 | 
			
		||||
			if (this.chartInstance) {
 | 
			
		||||
				this.chartInstance.destroy();
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			this.chartInstance = new ApexCharts(this.$refs.chart, {
 | 
			
		||||
				chart: {
 | 
			
		||||
					type: 'area',
 | 
			
		||||
					height: 300,
 | 
			
		||||
					animations: {
 | 
			
		||||
						dynamicAnimation: {
 | 
			
		||||
							enabled: false
 | 
			
		||||
						}
 | 
			
		||||
					},
 | 
			
		||||
					toolbar: {
 | 
			
		||||
						show: false
 | 
			
		||||
					},
 | 
			
		||||
					zoom: {
 | 
			
		||||
						enabled: false
 | 
			
		||||
					}
 | 
			
		||||
				},
 | 
			
		||||
				dataLabels: {
 | 
			
		||||
					enabled: false
 | 
			
		||||
				},
 | 
			
		||||
				grid: {
 | 
			
		||||
					clipMarkers: false,
 | 
			
		||||
					borderColor: 'rgba(0, 0, 0, 0.1)'
 | 
			
		||||
				},
 | 
			
		||||
				stroke: {
 | 
			
		||||
					curve: 'straight',
 | 
			
		||||
					width: 2
 | 
			
		||||
				},
 | 
			
		||||
				legend: {
 | 
			
		||||
					labels: {
 | 
			
		||||
						colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString()
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				xaxis: {
 | 
			
		||||
					type: 'datetime',
 | 
			
		||||
					labels: {
 | 
			
		||||
						style: {
 | 
			
		||||
							colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString()
 | 
			
		||||
						}
 | 
			
		||||
					},
 | 
			
		||||
					axisBorder: {
 | 
			
		||||
						color: 'rgba(0, 0, 0, 0.1)'
 | 
			
		||||
					},
 | 
			
		||||
					axisTicks: {
 | 
			
		||||
						color: 'rgba(0, 0, 0, 0.1)'
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				yaxis: {
 | 
			
		||||
					labels: {
 | 
			
		||||
						formatter: this.data.bytes ? v => Vue.filter('bytes')(v, 0) : v => Vue.filter('number')(v),
 | 
			
		||||
						style: {
 | 
			
		||||
							color: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString()
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				},
 | 
			
		||||
				series: this.data.series
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			this.chartInstance.render();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		getDate(i: number) {
 | 
			
		||||
			const y = this.now.getFullYear();
 | 
			
		||||
			const m = this.now.getMonth();
 | 
			
		||||
			const d = this.now.getDate();
 | 
			
		||||
			const h = this.now.getHours();
 | 
			
		||||
 | 
			
		||||
			return (
 | 
			
		||||
				this.chartSpan == 'day' ? new Date(y, m, d - i) :
 | 
			
		||||
				this.chartSpan == 'hour' ? new Date(y, m, d, h - i) :
 | 
			
		||||
				null
 | 
			
		||||
			);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		format(arr) {
 | 
			
		||||
			return arr.map((v, i) => ({ x: this.getDate(i).getTime(), y: v }));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		requestsChart(): any {
 | 
			
		||||
			return {
 | 
			
		||||
				series: [{
 | 
			
		||||
					name: 'Incoming',
 | 
			
		||||
					data: this.format(this.stats.requests.received)
 | 
			
		||||
				}, {
 | 
			
		||||
					name: 'Outgoing (succeeded)',
 | 
			
		||||
					data: this.format(this.stats.requests.succeeded)
 | 
			
		||||
				}, {
 | 
			
		||||
					name: 'Outgoing (failed)',
 | 
			
		||||
					data: this.format(this.stats.requests.failed)
 | 
			
		||||
				}]
 | 
			
		||||
			};
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		usersChart(total: boolean): any {
 | 
			
		||||
			return {
 | 
			
		||||
				series: [{
 | 
			
		||||
					name: 'Users',
 | 
			
		||||
					type: 'area',
 | 
			
		||||
					data: this.format(total
 | 
			
		||||
						? this.stats.users.total
 | 
			
		||||
						: sum(this.stats.users.inc, negate(this.stats.users.dec))
 | 
			
		||||
					)
 | 
			
		||||
				}]
 | 
			
		||||
			};
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		notesChart(total: boolean): any {
 | 
			
		||||
			return {
 | 
			
		||||
				series: [{
 | 
			
		||||
					name: 'Notes',
 | 
			
		||||
					type: 'area',
 | 
			
		||||
					data: this.format(total
 | 
			
		||||
						? this.stats.notes.total
 | 
			
		||||
						: sum(this.stats.notes.inc, negate(this.stats.notes.dec))
 | 
			
		||||
					)
 | 
			
		||||
				}]
 | 
			
		||||
			};
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		ffChart(total: boolean): any {
 | 
			
		||||
			return {
 | 
			
		||||
				series: [{
 | 
			
		||||
					name: 'Following',
 | 
			
		||||
					type: 'area',
 | 
			
		||||
					data: this.format(total
 | 
			
		||||
						? this.stats.following.total
 | 
			
		||||
						: sum(this.stats.following.inc, negate(this.stats.following.dec))
 | 
			
		||||
					)
 | 
			
		||||
				}, {
 | 
			
		||||
					name: 'Followers',
 | 
			
		||||
					type: 'area',
 | 
			
		||||
					data: this.format(total
 | 
			
		||||
						? this.stats.followers.total
 | 
			
		||||
						: sum(this.stats.followers.inc, negate(this.stats.followers.dec))
 | 
			
		||||
					)
 | 
			
		||||
				}]
 | 
			
		||||
			};
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		driveUsageChart(total: boolean): any {
 | 
			
		||||
			return {
 | 
			
		||||
				bytes: true,
 | 
			
		||||
				series: [{
 | 
			
		||||
					name: 'Drive usage',
 | 
			
		||||
					type: 'area',
 | 
			
		||||
					data: this.format(total
 | 
			
		||||
						? this.stats.drive.totalUsage
 | 
			
		||||
						: sum(this.stats.drive.incUsage, negate(this.stats.drive.decUsage))
 | 
			
		||||
					)
 | 
			
		||||
				}]
 | 
			
		||||
			};
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		driveFilesChart(total: boolean): any {
 | 
			
		||||
			return {
 | 
			
		||||
				series: [{
 | 
			
		||||
					name: 'Drive files',
 | 
			
		||||
					type: 'area',
 | 
			
		||||
					data: this.format(total
 | 
			
		||||
						? this.stats.drive.totalFiles
 | 
			
		||||
						: sum(this.stats.drive.incFiles, negate(this.stats.drive.decFiles))
 | 
			
		||||
					)
 | 
			
		||||
				}]
 | 
			
		||||
			};
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user