NodeBB/NodeBB

View on GitHub
public/src/admin/dashboard.js

Summary

Maintainability
D
2 days
Test Coverage
import {
    Chart,
    LineController,
    DoughnutController,
    CategoryScale,
    LinearScale,
    LineElement,
    PointElement,
    ArcElement,
    Tooltip,
    Filler,
    Legend,
} from 'chart.js';

import * as Benchpress from 'benchpressjs';
import * as bootbox from 'bootbox';
import * as alerts from '../modules/alerts';
import * as translator from '../modules/translator';
import { formattedNumber } from '../modules/helpers';

Chart.register(
    LineController,
    DoughnutController,
    CategoryScale,
    LinearScale,
    LineElement,
    PointElement,
    ArcElement,
    Tooltip,
    Filler,
    Legend
);

const intervals = {
    rooms: false,
    graphs: false,
};
let isMobile = false;
const graphData = {
    rooms: {},
    traffic: {},
};
const currentGraph = {
    units: 'hours',
    until: undefined,
};

const DEFAULTS = {
    roomInterval: 10000,
    graphInterval: 15000,
    realtimeInterval: 1500,
};

const usedTopicColors = [];

$(window).on('action:ajaxify.start', function () {
    clearInterval(intervals.rooms);
    clearInterval(intervals.graphs);

    intervals.rooms = null;
    intervals.graphs = null;
    graphData.rooms = null;
    graphData.traffic = null;
    usedTopicColors.length = 0;
});

// eslint-disable-next-line import/prefer-default-export
export function init() {
    app.enterRoom('admin');

    isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);

    setupDarkModeButton();
    setupRealtimeButton();
    setupGraphs(function () {
        socket.emit('admin.rooms.getAll', updateRoomUsage);
        initiateDashboard();
    });
    setupFullscreen();
}

function updateRoomUsage(err, data) {
    if (err) {
        return alerts.error(err);
    }

    if (JSON.stringify(graphData.rooms) === JSON.stringify(data)) {
        return;
    }

    graphData.rooms = data;

    updateRegisteredGraph(data.onlineRegisteredCount, data.onlineGuestCount);
    updatePresenceGraph(data.users);
    updateTopicsGraph(data.topTenTopics);

    $('#active-users-loggedin').text(formattedNumber(data.onlineRegisteredCount));
    $('#active-users-guests').text(formattedNumber(data.onlineGuestCount));
    $('#active-users-total').text(formattedNumber(data.onlineRegisteredCount + data.onlineGuestCount));
    $('#active-users-connections').text(formattedNumber(data.socketCount));
}

const graphs = {
    traffic: null,
    registered: null,
    presence: null,
    topics: null,
};

const topicColors = [
    '#bf616a', '#5B90BF', '#d08770', '#ebcb8b',
    '#a3be8c', '#96b5b4', '#8fa1b3', '#b48ead',
    '#ab7967', '#46BFBD',
];

/* eslint-disable */
// from chartjs.org
function lighten(col, amt) {
    let usePound = false;

    if (col[0] === '#') {
        col = col.slice(1);
        usePound = true;
    }

    const num = parseInt(col, 16);

    let r = (num >> 16) + amt;

    if (r > 255) r = 255;
    else if (r < 0) r = 0;

    let b = ((num >> 8) & 0x00FF) + amt;

    if (b > 255) b = 255;
    else if (b < 0) b = 0;

    let g = (num & 0x0000FF) + amt;

    if (g > 255) g = 255;
    else if (g < 0) g = 0;

    return (usePound ? '#' : '') + (g | (b << 8) | (r << 16)).toString(16);
}
/* eslint-enable */

function setupGraphs(callback) {
    callback = callback || function () {};
    const trafficCanvas = document.getElementById('analytics-traffic');
    const registeredCanvas = document.getElementById('analytics-registered');
    const presenceCanvas = document.getElementById('analytics-presence');
    const topicsCanvas = document.getElementById('analytics-topics');
    const trafficCtx = trafficCanvas.getContext('2d');
    const registeredCtx = registeredCanvas.getContext('2d');
    const presenceCtx = presenceCanvas.getContext('2d');
    const topicsCtx = topicsCanvas.getContext('2d');
    const trafficLabels = utils.getHoursArray();

    if (isMobile) {
        Chart.defaults.plugins.tooltip.enabled = false;
    }

    const t = translator.Translator.create();
    Promise.all([
        t.translateKey('admin/dashboard:graphs.page-views', []),
        t.translateKey('admin/dashboard:graphs.page-views-registered', []),
        t.translateKey('admin/dashboard:graphs.page-views-guest', []),
        t.translateKey('admin/dashboard:graphs.page-views-bot', []),
        t.translateKey('admin/dashboard:graphs.unique-visitors', []),
        t.translateKey('admin/dashboard:graphs.registered-users', []),
        t.translateKey('admin/dashboard:graphs.guest-users', []),
        t.translateKey('admin/dashboard:on-categories', []),
        t.translateKey('admin/dashboard:reading-posts', []),
        t.translateKey('admin/dashboard:browsing-topics', []),
        t.translateKey('admin/dashboard:recent', []),
        t.translateKey('admin/dashboard:unread', []),
    ]).then(function (translations) {
        const tension = 0.25;
        const data = {
            labels: trafficLabels,
            datasets: [
                {
                    label: translations[0],
                    fill: 'origin',
                    tension: tension,
                    backgroundColor: 'rgba(220,220,220,0.2)',
                    borderColor: 'rgba(220,220,220,1)',
                    pointBackgroundColor: 'rgba(220,220,220,1)',
                    pointHoverBackgroundColor: '#fff',
                    pointBorderColor: '#fff',
                    pointHoverBorderColor: 'rgba(220,220,220,1)',
                    data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                },
                {
                    label: translations[1],
                    fill: 'origin',
                    tension: tension,
                    backgroundColor: '#ab464233',
                    borderColor: '#ab4642',
                    pointBackgroundColor: '#ab4642',
                    pointHoverBackgroundColor: '#ab4642',
                    pointBorderColor: '#fff',
                    pointHoverBorderColor: '#ab4642',
                    data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                },
                {
                    label: translations[2],
                    fill: 'origin',
                    tension: tension,
                    backgroundColor: '#ba8baf33',
                    borderColor: '#ba8baf',
                    pointBackgroundColor: '#ba8baf',
                    pointHoverBackgroundColor: '#ba8baf',
                    pointBorderColor: '#fff',
                    pointHoverBorderColor: '#ba8baf',
                    data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                },
                {
                    label: translations[3],
                    fill: 'origin',
                    tension: tension,
                    backgroundColor: '#f7ca8833',
                    borderColor: '#f7ca88',
                    pointBackgroundColor: '#f7ca88',
                    pointHoverBackgroundColor: '#f7ca88',
                    pointBorderColor: '#fff',
                    pointHoverBorderColor: '#f7ca88',
                    data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                },
                {
                    label: translations[4],
                    fill: 'origin',
                    tension: tension,
                    backgroundColor: 'rgba(151,187,205,0.2)',
                    borderColor: 'rgba(151,187,205,1)',
                    pointBackgroundColor: 'rgba(151,187,205,1)',
                    pointHoverBackgroundColor: 'rgba(151,187,205,1)',
                    pointBorderColor: '#fff',
                    pointHoverBorderColor: 'rgba(151,187,205,1)',
                    data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                },
            ],
        };

        trafficCanvas.width = $(trafficCanvas).parent().width();

        data.datasets[0].yAxisID = 'left-y-axis';
        data.datasets[1].yAxisID = 'left-y-axis';
        data.datasets[2].yAxisID = 'left-y-axis';
        data.datasets[3].yAxisID = 'left-y-axis';
        data.datasets[4].yAxisID = 'right-y-axis';

        graphs.traffic = new Chart(trafficCtx, {
            type: 'line',
            data: data,
            options: {
                responsive: true,
                scales: {
                    'left-y-axis': {
                        position: 'left',
                        type: 'linear',
                        title: {
                            display: true,
                            text: translations[0],
                        },
                        beginAtZero: true,
                    },
                    'right-y-axis': {
                        position: 'right',
                        type: 'linear',
                        title: {
                            display: true,
                            text: translations[4],
                        },
                        beginAtZero: true,
                    },
                },
                plugins: {
                    legend: {
                        position: 'bottom',
                    },
                },
                interaction: {
                    intersect: false,
                    mode: 'index',
                },
            },
        });

        const doughnutOpts = {
            responsive: true,
            plugins: {
                legend: {
                    display: false,
                },
            },
        };
        graphs.registered = new Chart(registeredCtx, {
            type: 'doughnut',
            data: {
                labels: translations.slice(5, 7),
                datasets: [{
                    data: [1, 1],
                    backgroundColor: ['#F7464A', '#46BFBD'],
                    hoverBackgroundColor: ['#FF5A5E', '#5AD3D1'],
                }],
            },
            options: doughnutOpts,
        });

        graphs.presence = new Chart(presenceCtx, {
            type: 'doughnut',
            data: {
                labels: translations.slice(7, 12),
                datasets: [{
                    data: [1, 1, 1, 1, 1],
                    backgroundColor: ['#F7464A', '#46BFBD', '#FDB45C', '#949FB1', '#9FB194'],
                    hoverBackgroundColor: ['#FF5A5E', '#5AD3D1', '#FFC870', '#A8B3C5', '#A8B3C5'],
                }],
            },
            options: doughnutOpts,
        });

        graphs.topics = new Chart(topicsCtx, {
            type: 'doughnut',
            data: {
                labels: [],
                datasets: [{
                    data: [],
                    backgroundColor: [],
                    hoverBackgroundColor: [],
                }],
            },
            options: doughnutOpts,
        });

        updateTrafficGraph();

        $('[data-action="updateGraph"]:not([data-units="custom"])').on('click', function () {
            let until = new Date();
            const amount = $(this).attr('data-amount');
            if ($(this).attr('data-units') === 'days') {
                until.setHours(0, 0, 0, 0);
            }
            until = until.getTime();
            updateTrafficGraph($(this).attr('data-units'), until, amount);

            require(['translator'], function (translator) {
                translator.translate('[[admin/dashboard:page-views-custom]]', function (translated) {
                    $('[data-action="updateGraph"][data-units="custom"]').text(translated);
                });
            });
        });

        $('[data-action="updateGraph"][data-units="custom"]').on('click', function () {
            const targetEl = $(this);

            Benchpress.render('admin/partials/pageviews-range-select', {}).then(function (html) {
                const modal = bootbox.dialog({
                    title: '[[admin/dashboard:page-views-custom]]',
                    message: html,
                    buttons: {
                        submit: {
                            label: '[[global:search]]',
                            className: 'btn-primary',
                            callback: submit,
                        },
                    },
                }).on('shown.bs.modal', function () {
                    const date = new Date();
                    const today = date.toISOString().slice(0, 10);
                    date.setDate(date.getDate() - 1);
                    const yesterday = date.toISOString().slice(0, 10);

                    modal.find('#startRange').val(targetEl.attr('data-startRange') || yesterday);
                    modal.find('#endRange').val(targetEl.attr('data-endRange') || today);
                });

                function submit() {
                    // NEED TO ADD VALIDATION HERE FOR YYYY-MM-DD
                    const formData = modal.find('form').serializeObject();
                    const validRegexp = /\d{4}-\d{2}-\d{2}/;

                    // Input validation
                    if (!formData.startRange && !formData.endRange) {
                        // No range? Assume last 30 days
                        updateTrafficGraph('days');
                        return;
                    } else if (!validRegexp.test(formData.startRange) || !validRegexp.test(formData.endRange)) {
                        // Invalid Input
                        modal.find('.alert-danger').removeClass('hidden');
                        return false;
                    }

                    let until = new Date(formData.endRange);
                    until.setDate(until.getDate() + 1);
                    until = until.getTime();
                    const amount = (until - new Date(formData.startRange).getTime()) / (1000 * 60 * 60 * 24);

                    updateTrafficGraph('days', until, amount);

                    // Update "custom range" label
                    targetEl.attr('data-startRange', formData.startRange);
                    targetEl.attr('data-endRange', formData.endRange);
                    targetEl.html(formData.startRange + ' &ndash; ' + formData.endRange);
                }
            });
        });

        callback();
    });
}

function updateTrafficGraph(units, until, amount) {
    // until and amount are optional

    if (!app.isFocused) {
        return;
    }

    socket.emit('admin.analytics.get', {
        graph: 'traffic',
        units: units || 'hours',
        until: until,
        amount: amount,
    }, function (err, data) {
        if (err) {
            return alerts.error(err);
        }
        if (JSON.stringify(graphData.traffic) === JSON.stringify(data)) {
            return;
        }

        graphData.traffic = data;

        if (units === 'days') {
            graphs.traffic.data.xLabels = utils.getDaysArray(until, amount);
        } else {
            graphs.traffic.data.xLabels = utils.getHoursArray();

            $('#pageViewsThirty').html(formattedNumber(data.summary.thirty));
            $('#pageViewsSeven').html(formattedNumber(data.summary.seven));
            $('#pageViewsPastDay').html(formattedNumber(data.pastDay));
        }

        graphs.traffic.data.datasets[0].data = data.pageviews;
        graphs.traffic.data.datasets[1].data = data.pageviewsRegistered;
        graphs.traffic.data.datasets[2].data = data.pageviewsGuest;
        graphs.traffic.data.datasets[3].data = data.pageviewsBot;
        graphs.traffic.data.datasets[4].data = data.uniqueVisitors;
        graphs.traffic.data.labels = graphs.traffic.data.xLabels;

        graphs.traffic.update();
        currentGraph.units = units;
        currentGraph.until = until;
        currentGraph.amount = amount;

        // Update the View as JSON button url
        const apiEl = $('#view-as-json');
        const newHref = $.param({
            units: units || 'hours',
            until: until,
            count: amount,
        });
        apiEl.attr('href', config.relative_path + '/api/admin/analytics?' + newHref);
    });
}

function updateRegisteredGraph(registered, guest) {
    $('#analytics-legend .registered').parent().find('.count').text(registered);
    $('#analytics-legend .guest').parent().find('.count').text(guest);
    graphs.registered.data.datasets[0].data[0] = registered;
    graphs.registered.data.datasets[0].data[1] = guest;
    graphs.registered.update();
}

function updatePresenceGraph(users) {
    $('#analytics-presence-legend .on-categories').parent().find('.count').text(users.categories);
    $('#analytics-presence-legend .reading-posts').parent().find('.count').text(users.topics);
    $('#analytics-presence-legend .browsing-topics').parent().find('.count').text(users.category);
    $('#analytics-presence-legend .recent').parent().find('.count').text(users.recent);
    $('#analytics-presence-legend .unread').parent().find('.count').text(users.unread);
    graphs.presence.data.datasets[0].data[0] = users.categories;
    graphs.presence.data.datasets[0].data[1] = users.topics;
    graphs.presence.data.datasets[0].data[2] = users.category;
    graphs.presence.data.datasets[0].data[3] = users.recent;
    graphs.presence.data.datasets[0].data[4] = users.unread;

    graphs.presence.update();
}

function updateTopicsGraph(topics) {
    if (!topics.length) {
        translator.translate('[[admin/dashboard:no-users-browsing]]', function (translated) {
            topics = [{
                title: translated,
                count: 1,
            }];
            updateTopicsGraph(topics);
        });
        return;
    }

    graphs.topics.data.labels = [];
    graphs.topics.data.datasets[0].data = [];
    graphs.topics.data.datasets[0].backgroundColor = [];
    graphs.topics.data.datasets[0].hoverBackgroundColor = [];

    topics.forEach(function (topic, i) {
        graphs.topics.data.labels.push(topic.title);
        graphs.topics.data.datasets[0].data.push(topic.count);
        graphs.topics.data.datasets[0].backgroundColor.push(topicColors[i]);
        graphs.topics.data.datasets[0].hoverBackgroundColor.push(lighten(topicColors[i], 10));
    });

    function buildTopicsLegend() {
        let html = '';
        topics.forEach(function (t, i) {
            const link = t.tid ? '<a title="' + t.title + '"href="' + config.relative_path + '/topic/' + t.tid + '" target="_blank"> ' + t.title + '</a>' : t.title;
            const label = t.count === '0' ? t.title : link;

            html += '<li>' +
                '<div style="background-color: ' + topicColors[i] + ';"></div>' +
                '<span> (' + t.count + ') ' + label + '</span>' +
                '</li>';
        });
        $('#topics-legend').translateHtml(html);
    }

    buildTopicsLegend();
    graphs.topics.update();
}

function setupDarkModeButton() {
    let bsTheme = localStorage.getItem('data-bs-theme') || 'light';
    $('#toggle-dark-mode').prop('checked', bsTheme === 'dark')
        .on('click', function () {
            const isChecked = $(this).is(':checked');
            bsTheme = isChecked ? 'dark' : 'light';
            $('html').attr('data-bs-theme', bsTheme);
            localStorage.setItem('data-bs-theme', bsTheme);
        });
}

function setupRealtimeButton() {
    $('#toggle-realtime').on('click', function () {
        initiateDashboard($(this).is(':checked'));
    });
}

function initiateDashboard(realtime) {
    clearInterval(intervals.rooms);
    clearInterval(intervals.graphs);

    intervals.rooms = setInterval(function () {
        if (app.isFocused && socket.connected) {
            socket.emit('admin.rooms.getAll', updateRoomUsage);
        }
    }, realtime ? DEFAULTS.realtimeInterval : DEFAULTS.roomInterval);

    intervals.graphs = setInterval(function () {
        updateTrafficGraph(currentGraph.units, currentGraph.until, currentGraph.amount);
    }, realtime ? DEFAULTS.realtimeInterval : DEFAULTS.graphInterval);
}

function setupFullscreen() {
    const container = document.getElementById('analytics-panel');
    const $container = $(container);
    const btn = $container.find('#expand-analytics');
    let fsMethod;
    let exitMethod;

    if (container.requestFullscreen) {
        fsMethod = 'requestFullscreen';
        exitMethod = 'exitFullscreen';
    } else if (container.mozRequestFullScreen) {
        fsMethod = 'mozRequestFullScreen';
        exitMethod = 'mozCancelFullScreen';
    } else if (container.webkitRequestFullscreen) {
        fsMethod = 'webkitRequestFullscreen';
        exitMethod = 'webkitCancelFullScreen';
    } else if (container.msRequestFullscreen) {
        fsMethod = 'msRequestFullscreen';
        exitMethod = 'msCancelFullScreen';
    }

    if (fsMethod) {
        btn.on('click', function () {
            if ($container.hasClass('fullscreen')) {
                document[exitMethod]();
                $container.removeClass('fullscreen');
            } else {
                container[fsMethod]();
                $container.addClass('fullscreen');
            }
        });
    }
}