diff --git a/packages/client/src/components/instance-stats.vue b/packages/client/src/components/instance-stats.vue index f386a8de9a503e781b747a29381475b30b2a272c..9a1769a3a11816fc97f69953a1462f928ea0b4e5 100644 --- a/packages/client/src/components/instance-stats.vue +++ b/packages/client/src/components/instance-stats.vue @@ -1,81 +1,188 @@ <template> <div class="zbcjwnqg"> - <div class="selects" style="display: flex;"> - <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> - <optgroup :label="$ts.federation"> - <option value="federation">{{ $ts._charts.federation }}</option> - <option value="ap-request">{{ $ts._charts.apRequest }}</option> - </optgroup> - <optgroup :label="$ts.users"> - <option value="users">{{ $ts._charts.usersIncDec }}</option> - <option value="users-total">{{ $ts._charts.usersTotal }}</option> - <option value="active-users">{{ $ts._charts.activeUsers }}</option> - </optgroup> - <optgroup :label="$ts.notes"> - <option value="notes">{{ $ts._charts.notesIncDec }}</option> - <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option> - <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option> - <option value="notes-total">{{ $ts._charts.notesTotal }}</option> - </optgroup> - <optgroup :label="$ts.drive"> - <option value="drive-files">{{ $ts._charts.filesIncDec }}</option> - <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option> - </optgroup> - </MkSelect> - <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> - <option value="hour">{{ $ts.perHour }}</option> - <option value="day">{{ $ts.perDay }}</option> - </MkSelect> + <div class="main"> + <div class="body"> + <div class="selects" style="display: flex;"> + <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> + <optgroup :label="$ts.federation"> + <option value="federation">{{ $ts._charts.federation }}</option> + <option value="ap-request">{{ $ts._charts.apRequest }}</option> + </optgroup> + <optgroup :label="$ts.users"> + <option value="users">{{ $ts._charts.usersIncDec }}</option> + <option value="users-total">{{ $ts._charts.usersTotal }}</option> + <option value="active-users">{{ $ts._charts.activeUsers }}</option> + </optgroup> + <optgroup :label="$ts.notes"> + <option value="notes">{{ $ts._charts.notesIncDec }}</option> + <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option> + <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option> + <option value="notes-total">{{ $ts._charts.notesTotal }}</option> + </optgroup> + <optgroup :label="$ts.drive"> + <option value="drive-files">{{ $ts._charts.filesIncDec }}</option> + <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option> + </optgroup> + </MkSelect> + <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> + <option value="hour">{{ $ts.perHour }}</option> + <option value="day">{{ $ts.perDay }}</option> + </MkSelect> + </div> + <div class="chart"> + <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> + </div> + </div> </div> - <div class="chart"> - <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> + <div class="subpub"> + <div class="sub"> + <div class="title">Sub</div> + <canvas ref="subDoughnutEl"></canvas> + </div> + <div class="pub"> + <div class="title">Pub</div> + <canvas ref="pubDoughnutEl"></canvas> + </div> </div> </div> </template> -<script lang="ts"> -import { defineComponent, ref } from 'vue'; +<script lang="ts" setup> +import { onMounted } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + DoughnutController, +} from 'chart.js'; import MkSelect from '@/components/form/select.vue'; import MkChart from '@/components/chart.vue'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import * as os from '@/os'; -export default defineComponent({ - components: { - MkSelect, - MkChart, - }, +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + DoughnutController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); - props: { - chartLimit: { - type: Number, - required: false, - default: 90 +const props = withDefaults(defineProps<{ + chartLimit?: number; + detailed?: boolean; +}>(), { + chartLimit: 90, +}); + +const chartSpan = $ref<'hour' | 'day'>('hour'); +const chartSrc = $ref('active-users'); +let subDoughnutEl = $ref<HTMLCanvasElement>(); +let pubDoughnutEl = $ref<HTMLCanvasElement>(); + +const { handler: externalTooltipHandler1 } = useChartTooltip(); +const { handler: externalTooltipHandler2 } = useChartTooltip(); + +function createDoughnut(chartEl, tooltip, data) { + return new Chart(chartEl, { + type: 'doughnut', + data: { + labels: data.map(x => x.name), + datasets: [{ + backgroundColor: data.map(x => x.color), + data: data.map(x => x.value), + }], }, - detailed: { - type: Boolean, - required: false, - default: false + options: { + layout: { + padding: { + left: 8, + right: 8, + top: 8, + bottom: 8, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: tooltip, + }, + }, }, - }, - - setup() { - const chartSpan = ref<'hour' | 'day'>('hour'); - const chartSrc = ref('active-users'); + }); +} - return { - chartSrc, - chartSpan, - }; - }, +onMounted(() => { + os.apiGet('federation/stats').then(fedStats => { + createDoughnut(subDoughnutEl, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({ name: x.host, color: x.themeColor, value: x.followersCount })).concat([{ name: '(other)', color: '#808080', value: fedStats.otherFollowersCount }])); + createDoughnut(pubDoughnutEl, externalTooltipHandler1, fedStats.topPubInstances.map(x => ({ name: x.host, color: x.themeColor, value: x.followingCount })).concat([{ name: '(other)', color: '#808080', value: fedStats.otherFollowingCount }])); + }); }); </script> <style lang="scss" scoped> .zbcjwnqg { - > .selects { + > .main { + background: var(--panel); + border-radius: var(--radius); + padding: 24px; + margin-bottom: 16px; + + > .body { + > .chart { + padding: 8px 0 0 0; + } + } } - > .chart { - padding: 8px 0 0 0; + > .subpub { + display: flex; + gap: 16px; + + > .sub, > .pub { + position: relative; + background: var(--panel); + border-radius: var(--radius); + padding: 24px; + + > .title { + position: absolute; + top: 24px; + left: 24px; + } + } } } </style> diff --git a/packages/client/src/components/object-view.value.vue b/packages/client/src/components/object-view.value.vue index 6f388636dd9f54c1e9bc4e601a90908fcaac8b54..0c7230d7830ff26390039350050ec6a112750ccc 100644 --- a/packages/client/src/components/object-view.value.vue +++ b/packages/client/src/components/object-view.value.vue @@ -1,31 +1,35 @@ <template> <div class="igpposuu _monospace"> <div v-if="value === null" class="null">null</div> - <div v-else-if="typeof value === 'boolean'" class="boolean">{{ value ? 'true' : 'false' }}</div> + <div v-else-if="typeof value === 'boolean'" class="boolean" :class="{ true: value, false: !value }">{{ value ? 'true' : 'false' }}</div> <div v-else-if="typeof value === 'string'" class="string">"{{ value }}"</div> <div v-else-if="typeof value === 'number'" class="number">{{ number(value) }}</div> - <div v-else-if="Array.isArray(value)" class="array"> - <button @click="collapsed_ = !collapsed_">[ {{ collapsed_ ? '+' : '-' }} ]</button> - <template v-if="!collapsed_"> - <div v-for="i in value.length" class="element"> - {{ i }}: <XValue :value="value[i - 1]" collapsed/> - </div> - </template> + <div v-else-if="isArray(value) && isEmpty(value)" class="array empty">[]</div> + <div v-else-if="isArray(value)" class="array"> + <div v-for="i in value.length" class="element"> + {{ i }}: <XValue :value="value[i - 1]" collapsed/> + </div> </div> - <div v-else-if="typeof value === 'object'" class="object"> - <button @click="collapsed_ = !collapsed_">{ {{ collapsed_ ? '+' : '-' }} }</button> - <template v-if="!collapsed_"> - <div v-for="k in Object.keys(value)" class="kv"> - <div class="k">{{ k }}:</div> - <div class="v"><XValue :value="value[k]" collapsed/></div> + <div v-else-if="isObject(value) && isEmpty(value)" class="object empty">{}</div> + <div v-else-if="isObject(value)" class="object"> + <div v-for="k in Object.keys(value)" class="kv"> + <button class="toggle _button" :class="{ visible: collapsable(value[k]) }" @click="collapsed[k] = !collapsed[k]">{{ collapsed[k] ? '+' : '-' }}</button> + <div class="k">{{ k }}:</div> + <div v-if="collapsed[k]" class="v"> + <button class="_button" @click="collapsed[k] = !collapsed[k]"> + <template v-if="typeof value[k] === 'string'">"..."</template> + <template v-else-if="isArray(value[k])">[...]</template> + <template v-else-if="isObject(value[k])">{...}</template> + </button> </div> - </template> + <div v-else class="v"><XValue :value="value[k]"/></div> + </div> </div> </div> </template> <script lang="ts"> -import { computed, defineComponent, ref } from 'vue'; +import { computed, defineComponent, reactive, ref } from 'vue'; import number from '@/filters/number'; export default defineComponent({ @@ -33,24 +37,44 @@ export default defineComponent({ props: { value: { - type: Object, required: true, }, - collapsed: { - type: Boolean, - required: false, - default: false, - }, }, setup(props) { - const collapsed_ = ref(props.collapsed); + const collapsed = reactive({}); + + if (isObject(props.value)) { + for (const key in props.value) { + collapsed[key] = collapsable(props.value[key]); + } + } + + function isObject(v): boolean { + return typeof v === 'object' && !Array.isArray(v) && v !== null; + } + + function isArray(v): boolean { + return Array.isArray(v); + } + + function isEmpty(v): boolean { + return (isArray(v) && v.length === 0) || (isObject(v) && Object.keys(v).length === 0); + } + + function collapsable(v): boolean { + return (isObject(v) || isArray(v)) && !isEmpty(v); + } return { number, - collapsed_, + collapsed, + isObject, + isArray, + isEmpty, + collapsable, }; - } + }, }); </script> @@ -66,6 +90,14 @@ export default defineComponent({ > .boolean { display: inline; color: var(--codeBoolean); + + &.true { + font-weight: bold; + } + + &.false { + opacity: 0.7; + } } > .string { @@ -78,7 +110,12 @@ export default defineComponent({ color: var(--codeNumber); } - > .array { + > .array.empty { + display: inline; + opacity: 0.7; + } + + > .array:not(.empty) { display: inline; > .element { @@ -87,13 +124,28 @@ export default defineComponent({ } } - > .object { + > .object.empty { + display: inline; + opacity: 0.7; + } + + > .object:not(.empty) { display: inline; > .kv { display: block; padding-left: 16px; + > .toggle { + width: 16px; + color: var(--accent); + visibility: hidden; + + &.visible { + visibility: visible; + } + } + > .k { display: inline; margin-right: 8px; diff --git a/packages/client/src/components/object-view.vue b/packages/client/src/components/object-view.vue index e9db96de8c0c5f2e8b2d27ba09229759991ca359..db66049fce62efdffc67315944b2af1ee382949b 100644 --- a/packages/client/src/components/object-view.vue +++ b/packages/client/src/components/object-view.vue @@ -4,26 +4,13 @@ </div> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import XValue from './object-view.value.vue'; -export default defineComponent({ - components: { - XValue - }, - - props: { - value: { - type: Object, - required: true, - }, - }, - - setup(props) { - - } -}); +const props = defineProps<{ + value: Record<string, unknown>; +}>(); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue index bacfab771f57525e9fee25bec379e6862d45f004..de89e3593cbdb3ca66ac5d1742718be1198256ea 100644 --- a/packages/client/src/pages/about.vue +++ b/packages/client/src/pages/about.vue @@ -73,7 +73,7 @@ <MkSpacer v-else-if="tab === 'federation'" :content-max="1000" :margin-min="20"> <XFederation/> </MkSpacer> - <MkSpacer v-else-if="tab === 'charts'" :content-max="1200" :margin-min="20"> + <MkSpacer v-else-if="tab === 'charts'" :content-max="1000" :margin-min="20"> <MkInstanceStats :chart-limit="500" :detailed="true"/> </MkSpacer> </MkStickyContainer> diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue index 9dfb2d87a024719af91349f33540f157be21bc43..76b772ece2c40debf3f5fc4d0c21805f6f215b45 100644 --- a/packages/client/src/pages/user-info.vue +++ b/packages/client/src/pages/user-info.vue @@ -294,7 +294,7 @@ const headerTabs = $computed(() => [{ icon: 'fas fa-share-alt', }, { key: 'raw', - title: 'Raw data', + title: 'Raw', icon: 'fas fa-code', }].filter(x => x != null));