/**
* @file Bulletin Board Dashboard
* @author Andrew Sayers
* @description Display continuously-updated information about a bulletin board
*/
// notification thread page
// communication with notification tab
/**
* @summary Dashboard displaying miscellaneous real-time information
* @constructor
* @extends Cacheable
* @example
* var dashboard = new Dashboard({
* cache : { ... }, // values previously passed to cache, or an empty object on first run
* cache_updater : function(cache) { ... }, // save object to pass in next time
* container : $(...), // element containing dashboard content
* interval : 60000 // each monitor will be polled once per this many milliseconds
* });
*
* @description
* Dashboards display information about the bulletin board you're on.
* Currently supported: 'thread', 'forum', 'mod_queue' and 'newbies'.
*
* Most configuration is done through the HTML in the "container" parameter.
* Elements with class "dashboard-header" have the dashboard header inserted.
* Elements with class "dashboard-section" are set to dashboard sections (based on their "data-monitor" parameter)
* Elements with class "dashboard-body" within "dashboard-section" receive the contents of the section
* Elements with class "dashboard-refresh" will trigger a refresh of the current section when clicked
* Elements with class "dashboard-done" are treated as buttons to (un)mark a section done
*
* Section elements have classes "loading", "empty", "nonempty", "done" and "undone" set as appropriate.
* Section elements can have a 'bb' data item if they need to access a different bulletin board
*/
function Dashboard( args ) {
Cacheable.call( this, args );
this.bb = args.bb;
/*
* INITIALISE HEADER
*/
args.container.find('.dashboard-header').each(function() {
/*
* If you temporarily disable the extension, all running intervals will be cancelled.
* This block uses a CSS animation to warn the user when that happens.
*/
$("head").append(
"<style type='text/css'>" +
'@-webkit-keyframes dashboard-warning { 0%, 99% {opacity: 0; visibility: hidden} 100% {opacity: 1; visibility: visible} }' +
'@keyframes dashboard-warning { 0%, 99% {opacity: 0; visibility: hidden} 100% {opacity: 1; visibility: visible} }' +
'.dashboard-warning' +
'{ animation: dashboard-warning ' + Math.ceil(args.interval / 500) + 's;' +
' -webkit-animation: dashboard-warning ' + Math.ceil(args.interval / 500) + 's;' +
'float: none ! important; color: red}' +
"</style>"
);
var warning_container =
$('<div>Dashboard<span class="dashboard-warning"> WARNING: dashboard has stopped. Please refresh the page</span></div>')
.appendTo(this);
setInterval(function() {
warning_container.find('.dashboard-warning').replaceWith( warning_container.find('.dashboard-warning').clone() );
}, args.interval );
});
/*
* INITIALISE SECTIONS
*/
var sections = args.container.find('.dashboard-section');
var interval = args.interval;
var tick = interval / sections.length, tick_count = 0;
var dashboard = this;
sections.each(function() {
var container = $(this), monitor = container.data('monitor'), bb = container.data('bb') || dashboard.bb;
if ( !monitor || !Dashboard.prototype.hasOwnProperty(monitor+'_refresh') ) {
console.log("Ignoring unknown dashboard monitor: " + monitor);
return;
}
// initialise "done" buttons:
var done_time = 0;
container.find('.dashboard-done').click(function(event) {
done_time = new Date().getTime();
dashboard[monitor+'_done']( bb, container, container.hasClass('done') );
dashboard.update_cache();
container.toggleClass('done undone');
event.preventDefault();
});
// empty the container in a non-undoable way:
container.data('empty', function(event) {
done_time = new Date().getTime();
dashboard[monitor+'_done']( bb, container, container.hasClass('done') );
dashboard.update_cache();
container.removeClass('undone nonempty').addClass( 'done empty' );
});
$(container).on( 'mouseover click', function() { done_time = new Date().getTime() });
function refresh(force) {
// don't change anything while people are looking:
if ( force || ( done_time < new Date().getTime() - interval/2 && ! container.is(':hover') ) ) {
done_time = new Date().getTime();
var old_class = container.attr( 'class' );
// set the section's state to look right while loading:
if ( container.hasClass('done') ) container.addClass('empty');
container.removeClass( 'nonempty done undone' ).addClass( 'loading' );
dashboard[monitor+'_refresh'](bb, container).then(function(contents) {
container.removeClass( 'empty loading' )
if ( typeof(contents) == 'undefined' ) {
container.attr( 'class', old_class );
} else {
container.find('.dashboard-body').empty().append(contents);
container.addClass(
contents.length
? 'nonempty undone'
: 'empty'
);
}
});
}
}
// initialise "refresh" buttons:
container.find('a.dashboard-refresh').click(function(event) {
refresh(true);
event.preventDefault();
});
// initialise the section:
container.addClass( 'empty' );
container.data( 'signature', '' );
var promise = Dashboard.prototype.hasOwnProperty(monitor+'_init') ? dashboard[monitor+'_init'](bb, container) : false;
if ( promise ) {
done_time = new Date().getTime(); + Math.pow( 10, 10 ); // disable refreshes until initialised
promise.then(function() { refresh(true) });
} else {
refresh(true);
}
// fire the refresh callback once per interval,
// with sections spaced out evenly across the interval:
setTimeout(function() { setInterval( refresh, interval ) }, tick * tick_count++ );
});
}
Dashboard.prototype = Object.create(Cacheable.prototype, {
bb: { writable: true, configurable: false },
});
Dashboard.prototype.constructor = Dashboard;
/**
* @summary show notifications registered by the dashboard
*/
function show_dashboard_notifications(cache) {
}
function add_notification( title, notification_html, body_html ) {
}
/*
* THREAD MONITORING
*/
Dashboard.prototype.thread_init = function(bb, container) {
if ( !this.cache['thread-done-'+container.data('thread')] ) {
var dashboard = this;
var thread_id = container.data('thread');
return $.get(bb.url_for.thread_show({ thread_id: thread_id, goto: 'newpost' })).then(function(html) {
// first run - get highest post ID:
return bb.thread_posts( thread_id, html ).then(function(posts) {
dashboard.cache['thread-done-'+thread_id] = posts[posts.length-1].post_id;
dashboard.update_cache();
});
});
}
}
Dashboard.prototype.thread_done = function(bb, container, undo) {
this.cache['thread-done-'+container.data('thread')] = container.data( undo ? 'undone_id' : 'done_id' );
}
Dashboard.prototype.thread_refresh = function(bb, container) {
var dashboard = this, thread_id = container.data( 'thread' );
return bb.thread_whoposted(thread_id).then(function(who_posted) {
// thread pages are very expensive to load - we use the "who posted" page as a cheaper way to generate a signature:
var signature = who_posted.total + ',' + who_posted.users.map(function(user) { return user.post_count }).join();
if ( container.data('signature') == signature ) return;
var read_post_id = dashboard.cache['thread-done-'+thread_id];
container.data( 'undone_id', read_post_id );
return $.get(bb.url_for.thread_show({ thread_id: thread_id, post_id: read_post_id })).then(function(html) {
return bb.thread_posts( thread_id, html ).then(function(posts) {
container.data( 'signature', signature );
container.data( 'done_id', posts[posts.length-1].post_id );
posts = posts.filter(function(post) { return post.post_id > read_post_id });
if ( container.data('filter') ) posts = container.data('filter')(posts);
return posts.map(function(post) { return post.container_element });
});
});
});
}
/*
* FORUM MONITORING
*/
Dashboard.prototype.forum_done = function(bb, container, undo) {
this.cache['forum-done-'+container.data('forum')] = container.data( undo ? 'undone_id' : 'done_id' );
}
Dashboard.prototype.forum_refresh = function(bb, container) {
var dashboard = this, forum_id = container.data('forum');
// forum pages are less expensive than thread pages, so we don't bother caching them:
var read_post_id = dashboard.cache['forum-done-'+forum_id];
return bb.forum_threads(forum_id, true).then(function(threads) {
var min_thread_id = container.data('min_thread_id') || 0;
var last_post_ids = threads.map(function(thread) { return thread.last_post_id });
// ignore if unchanged
if ( dashboard.cache['forum-min-thread-'+forum_id] == min_thread_id && container.data('signature') == last_post_ids.join() ) return;
container.data( 'signature', last_post_ids.join() );
container.data( 'done_id', Math.max.apply( Math, last_post_ids ) );
container.data( 'undone_id', read_post_id );
dashboard.cache['forum-min-thread-'+forum_id] = min_thread_id;
if ( read_post_id ) threads = threads.filter(function(thread) { return thread.last_post_id > read_post_id });
if ( min_thread_id ) threads = threads.filter(function(thread) { return thread.thread_id >= min_thread_id });
if ( container.data('filter') ) threads = container.data('filter')(threads);
return threads.map(function(thread) { return thread.container_element });
});
}
/*
* RECENT ACTIVITY MONITORING
*/
Dashboard.prototype.activity_done = function(bb, container, undo) {
this.cache['activity-done'] = container.data( undo ? 'undone_id' : 'done_id' );
}
Dashboard.prototype.activity_refresh = function(bb, container) {
var dashboard = this, activity_id = container.data('activity');
// activity pages are less expensive than thread pages, so we don't bother caching them:
var max_read = dashboard.cache['activity-done'] || [ 0, 0, 0 ];
if ( !$.isArray(max_read) ) max_read = [ max_read, 0, 0 ]; // upgrade old values, can be deleted after 2015-10-21
return bb.activity(max_read[0]+1, max_read[1]+1).then(function(activity_data) {
container.data( 'undone_id', max_read );
container.data( 'done_id', [
Math.max( max_read[0], activity_data.max_date ),
Math.max( max_read[1], activity_data.max_post_id ),
Math.max( max_read[2], activity_data.max_thread_id )
]);
var posts = activity_data.posts;
// Activity filter may not remove all posts, so we have to filter ourselves:
posts = posts.filter(function(post) { return post.post_id ? post.post_id > max_read[1] : post.thread_id > max_read[2] });
if ( container.data('filter') ) posts = container.data('filter')(posts);
return posts.map(function(post) { return post.container_element });
});
}
/*
* NEWBIE MONITORING
*/
Dashboard.prototype.newbies_init = function(bb, container) {
var dashboard = this;
container.data( 'template', container.find( '.template' ).removeClass( 'template' ).detach() );
if ( (dashboard.cache['newbies-data-version']||0) != 2 ) {
delete dashboard.cache['newbies-next'];
dashboard.cache['newbies-data-version'] = 2;
}
if ( !dashboard.cache['newbies-next'] ) {
dashboard.cache['newbies-current'] = [];
if ( container.data('min_user_id') ) {
dashboard.cache['newbies-next'] = container.data('min_user_id') + 1;
dashboard.update_cache();
} else {
// first run - get min_user_id if possible, else set it to the next user ID that will be created:
return bb.users_list_new()
.then(function(users) {
dashboard.cache['newbies-next'] = users[0].user_id + 1;
dashboard.update_cache();
});
}
}
}
Dashboard.prototype.newbies_done = function(bb, container, undo) {
this.cache['newbies-current'] = undo ? container.data( 'newbies-current' ) : [];
}
Dashboard.prototype.newbies_refresh = function(bb, container) {
var dashboard = this;
var current_users = dashboard.cache['newbies-current'];
// approximate amount of time to spend downloading new user accounts:
var end_time = new Date().getTime() + 10000;
var user_count = 0;
// get a new user account (called recursively until the time limit is reached):
function get_user(user_info) {
if ( user_info ) {
++dashboard.cache['newbies-next'];
current_users.push(user_info);
dashboard.update_cache();
if ( new Date().getTime() < end_time ) // get another account
return bb.user_duplicates(dashboard.cache['newbies-next']).then(get_user)
}
}
function get_newbies(user_id) {
if ( dashboard.cache['newbies-next'] < user_id )
dashboard.cache['newbies-next'] = user_id;
// dashboard.cache['newbies-next'] = 90000; // uncomment this to force some newbies to be checked
current_users = current_users.filter(function(user) {
if ( user.user_id >= user_id ) return true
container.data( 'signature', false );
return false;
});
return bb.user_duplicates(dashboard.cache['newbies-next']).then(get_user).then(function() {
// If the section has already been initialised and there are no new users, return unchanged:
if ( container.data( 'signature' ) && !user_count ) return;
container.data( 'signature', true );
current_users.sort(function (a,b) {
if ( a.suspected_duplicates.filter(function(user) { return user.user_id == b.user_id }).length ) {
// groups of duplicate users are sorted by activity time
return b.activity_date - a.activity_date;
} else {
return b.suspiciousness - a.suspiciousness || a.username.localeCompare(b.username);
};
});
container.data( 'newbies-current', dashboard.cache['newbies-current'] = current_users );
dashboard.update_cache();
var template = container.data( 'template' );
var users = current_users.map(function(user) {
return $.extend( { element: template.clone() }, user );
});
if ( container.data('filter') ) users = container.data('filter')(users);
return users.map(function(user) { return user.element });
});
}
return get_newbies( container.data('min_user_id') || 0 );
}
/*
* POST QUEUE MONITORING
*/
Dashboard.prototype.mod_queue_done = function(bb, container, undo) {
this.cache['moderated-next-post' ] = container.data( undo ? 'post-undone' : 'post-done' );
this.cache['moderated-next-thread'] = container.data( undo ? 'thread-undone' : 'thread-done' );
}
Dashboard.prototype.mod_queue_refresh = function(bb, container) {
var dashboard = this;
var read_post_id = dashboard.cache['moderated-next-post' ];
var read_thread_id = dashboard.cache['moderated-next-thread'];
return bb.posts_moderated().then(function(posts_and_threads) {
var posts = posts_and_threads.posts, threads = posts_and_threads.threads;
var signature =
threads.map(function(thread) { return thread.thread_id }).concat(
posts.map(function(post ) { return post .post_id })
).join();
if ( signature == container.data( 'signature' ) ) return;
container.data( 'signature', signature );
container
.data( 'post-done', posts.length ? posts [posts .length-1]. post_id : read_post_id )
.data( 'thread-done', threads.length ? threads[threads.length-1].thread_id : read_thread_id );
if ( read_post_id ) posts = posts .filter(function(post ) { return post .post_id > read_post_id });
if ( read_thread_id ) threads = threads.filter(function(thread) { return thread.thread_id > read_thread_id });
return threads.map(function(thread) {
var ret = $('<tr><td><a href=""></a><td><a href=""></a><td><a href=""></a><td><a href=""></a></a></tr>');
var links = ret.find('a');
links.eq(0).attr('href', bb.url_for. forum_show({ forum_id: thread. forum_id }) ).text( thread.forum_name );
links.eq(1).attr('href', bb.url_for.thread_show({ thread_id: thread.thread_id }) ).text( thread.thread_title );
links.eq(2).attr('href', bb.url_for.thread_show({ thread_id: thread.thread_id }) ).text( '(first post)' );
links.eq(3).attr('href', bb.url_for. user_show({ user_id: thread. user_id }) ).text( thread.username );
return ret;
}).concat(posts.map(function(post) {
var ret = $('<tr><td><a href=""></a><td><a href=""><td><a href=""><td><a href=""></a></a></tr>');
var links = ret.find('a');
links.eq(0).attr('href', bb.url_for. forum_show({ forum_id: post. forum_id }) ).text( post.forum_name );
links.eq(1).attr('href', bb.url_for.thread_show({ thread_id: post.thread_id }) ).text( post.thread_title );
links.eq(2).attr('href', bb.url_for. post_show({ thread_id: post.thread_id, post_id: post.post_id }) ).text( post. post_title || '(no title)' );
links.eq(3).attr('href', bb.url_for. user_show({ user_id: post. user_id }) ).text( post.username );
return ret;
}));
});
}
// Some monitors need more than the default initialisation:
Dashboard.prototype.server_stats_init = function(bb, container) {
function make_chart( name, data, settings ) {
var chart = new Chart( container.find('.dashboard-server_stats-'+name+' canvas')[0].getContext("2d") );
var line = chart.Line(data, settings);
container.find('.dashboard-server_stats-'+name).append(line.generateLegend());
container.data( name, line );
}
if ( !this.cache['server-stats'] ) { // first run
this.cache['server-stats'] = {
labels: [],
one_minute_loadavg: [],
five_minute_loadavg: [],
fifteen_minute_loadavg: [],
members_online: [],
guests_online: []
};
}
var values = this.cache['server-stats'];
make_chart(
'loadavg',
{
labels: values.labels.slice(0),
datasets: [
{
label: "One-minute load average",
fillColor: "rgba(220,220,220,0.2)",
strokeColor: "#f33",
pointColor: "#f33",
pointStrokeColor: "#fff",
pointHighlightFill: "#fff",
pointHighlightStroke: "rgba(220,220,222,1)",
data: values.one_minute_loadavg
},
{
label: "Five-minute load average",
fillColor: "rgba(220,220,223,0.2)",
strokeColor: "#e88",
pointColor: "#e88",
pointStrokeColor: "#fff",
pointHighlightFill: "#fff",
pointHighlightStroke: "rgba(220,220,225,1)",
data: values.five_minute_loadavg
},
{
label: "Fifteen-minute load average",
fillColor: "rgba(220,220,226,0.2)",
strokeColor: "#daa",
pointColor: "#daa",
pointStrokeColor: "#fff",
pointHighlightFill: "#fff",
pointHighlightStroke: "rgba(220,220,228,1)",
data: values.fifteen_minute_loadavg
},
]
},
{
bezierCurve: true,
animation: false,
scaleOverride: true,
scaleSteps: 10,
scaleStepWidth: 0.1,
scaleStartValue: 0
}
);
make_chart(
'online',
{
labels: values.labels.slice(0),
datasets: [
{
label: "Members online",
fillColor: "rgba(220,220,220,0.2)",
strokeColor: "#3f3",
pointColor: "#3f3",
pointStrokeColor: "#fff",
pointHighlightFill: "#fff",
pointHighlightStroke: "rgba(220,220,220,1)",
data: values.members_online
},
{
label: "Guests online",
fillColor: "rgba(220,220,220,0.2)",
strokeColor: "#33f",
pointColor: "#33f",
pointStrokeColor: "#fff",
pointHighlightFill: "#fff",
pointHighlightStroke: "rgba(220,220,220,1)",
data: values.guests_online
},
]
},
{
bezierCurve: true,
animation: false,
scaleBeginAtZero: true
}
);
}
Dashboard.prototype.server_stats_done = function(bb, container, undo) {}
// called when it's time to refresh the list:
Dashboard.prototype.server_stats_refresh = function(bb, container) {
var dashboard = this;
return this.bb.server_stats().then(function(stats) {
var time = new Date();
time =
time.getHours() + ':' +
( time.getMinutes() < 10 ? '0' : '' ) + time.getMinutes() + ':' +
( time.getSeconds() < 10 ? '0' : '' ) + time.getSeconds()
;
var values = dashboard.cache['server-stats'];
values.labels.push( time );
values. one_minute_loadavg.push( stats. one_minute_loadavg );
values. five_minute_loadavg.push( stats. five_minute_loadavg );
values.fifteen_minute_loadavg.push( stats.fifteen_minute_loadavg );
values.members_online.push( stats.members_online );
values. guests_online.push( stats. guests_online );
var loadavg = container.data('loadavg');
var online = container.data('online');
if ( values.labels.length >= 15 ) {
loadavg.removeData();
online .removeData();
Object.keys(values).forEach(function(key) { values[key].shift() });
}
dashboard.update_cache();
loadavg.addData( [ stats.one_minute_loadavg, stats.five_minute_loadavg, stats.fifteen_minute_loadavg ], time );
online .addData( [ stats.members_online, stats.guests_online ], time );
});
}
/*
* PM FOLDER MONITORING
*/
Dashboard.prototype.folder_init = function(bb, container) {
if ( !this.cache['folder-done-'+container.data('id')] ) {
var dashboard = this;
return this.bb.folder_pms(container.data('folder')).then(function(pms) {
dashboard.cache['folder-done-'+container.data('id')] = pms.length ? pms[0].pm_id : 0;
dashboard.update_cache();
});
}
}
// called when the user clicks the "done" or "undone" button:
Dashboard.prototype.folder_done = function(bb, container, undo) {
// update the cache so future calls to refresh() act as if the monitor has been (un)done
this.cache['folder-done-'+container.data('id')] = container.data( undo ? 'undone_id' : 'done_id' );
}
// called when it's time to refresh the list:
Dashboard.prototype.folder_refresh = function(bb, container) {
var dashboard = this, folder_id = container.data('id');
var done_id = dashboard.cache['folder-done-'+folder_id];
return bb.folder_pms(folder_id).then(function(pms) {
if ( !pms.length || done_id == pms[0].pm_id ) return;
container.data( 'undone_id', done_id );
container.data( 'done_id', pms[0].pm_id );
pms = pms.filter(function(pm) { return pm.pm_id > done_id });
if ( container.data('filter') ) pms = container.data('filter')(pms);
return pms.map(function(pm) { return pm.container_element });
});
}
/*
* EXAMPLE MONITORING
*/
// Copy/paste this monitor to make your own monitor
// It will be registered and named automatically based on the function names:
/*
// Some monitors need more than the default initialisation:
Dashboard.prototype.example_init = function(bb, container) {
// init() can optionally return a Deferred object:
return $.get(...).then(function(html) {
// 'signature' is set to '' by default, but you can override it:
container.data( 'signature', $(html).find('.initial-value') );
});
}
*/
// called when the user clicks the "done" or "undone" button:
Dashboard.prototype.example_done = function(bb, container, undo) {
// update the cache so future calls to refresh() act as if the monitor has been (un)done
this.cache['example-data'] = container.data( undo ? 'undone_id' : 'done_id' );
}
// called when it's time to refresh the list:
Dashboard.prototype.example_refresh = function(bb, container) {
var dashboard = this, example_id = container.data('example');
var id = dashboard.cache['example-data'];
return bb.example(/*...*/).then(function(ret) {
// return an undefined value to indicate the state hasn't changed:
if ( container.data('signature') == ret.signature ) return;
container.data( 'signature', ret.signature );
// update stored values after checking signature:
container.data( 'undone_id', id );
container.data( 'done_id', ret.id );
// most monitors have use for some kind of filter:
if ( id ) ret = ret.filter(function(thread) { return ret.id > id });
if ( container.data('filter') ) ret = container.data('filter')(ret);
// return an array (or jQuery container) of elements to populate the body:
return ret.map(function(thread) { /* ... */ });
});
}