From fdd6920ae7173a355f4fb533c9888657022d9ad4 Mon Sep 17 00:00:00 2001
From: Guo Xiang Tan <tgx_world@hotmail.com>
Date: Wed, 17 Feb 2016 16:44:53 +0800
Subject: [PATCH] FEATURE: Admin Banner when Logster logs is getting flooded.

---
 .../discourse/components/global-notice.js.es6 | 32 ++++++++-
 .../discourse/initializers/logs-notice.js.es6 | 18 +++++
 .../discourse/services/logs-notice.js.es6     | 69 +++++++++++++++++++
 config/initializers/100-logster.rb            | 19 +++++
 config/locales/client.en.yml                  |  1 +
 config/locales/server.en.yml                  |  3 +
 config/site_settings.yml                      |  8 +++
 7 files changed, 148 insertions(+), 2 deletions(-)
 create mode 100644 app/assets/javascripts/discourse/initializers/logs-notice.js.es6
 create mode 100644 app/assets/javascripts/discourse/services/logs-notice.js.es6

diff --git a/app/assets/javascripts/discourse/components/global-notice.js.es6 b/app/assets/javascripts/discourse/components/global-notice.js.es6
index 080427d80..852f19ecd 100644
--- a/app/assets/javascripts/discourse/components/global-notice.js.es6
+++ b/app/assets/javascripts/discourse/components/global-notice.js.es6
@@ -1,4 +1,7 @@
+import { on } from 'ember-addons/ember-computed-decorators';
 import StringBuffer from 'discourse/mixins/string-buffer';
+import { iconHTML } from 'discourse/helpers/fa-icon';
+import LogsNotice from 'discourse/services/logs-notice';
 
 export default Ember.Component.extend(StringBuffer, {
   rerenderTriggers: ['site.isReadOnly'],
@@ -18,8 +21,33 @@ export default Ember.Component.extend(StringBuffer, {
       notices.push([this.siteSettings.global_notice, 'alert-global-notice']);
     }
 
-    if (notices.length > 0) {
-      buffer.push(_.map(notices, n => "<div class='row'><div class='alert alert-info " + n[1] + "'>" + n[0] + "</div></div>").join(""));
+    if (!LogsNotice.currentProp('hidden')) {
+      notices.push([LogsNotice.currentProp('message'), 'alert-logs-notice', `<div class='close'>${iconHTML('times')}</div>`]);
     }
+
+    if (notices.length > 0) {
+      buffer.push(_.map(notices, n => {
+        var html = `<div class='row'><div class='alert alert-info ${n[1]}'>${n[0]}`;
+        if (n[2]) html += n[2];
+        html += '</div></div>';
+        return html;
+      }).join(""));
+    }
+  },
+
+  @on('didInsertElement')
+  _setupLogsNotice() {
+    LogsNotice.current().addObserver('hidden', () => {
+      this.rerenderString();
+    });
+
+    this.$().on('click.global-notice', '.alert-logs-notice .close', () => {
+      LogsNotice.currentProp('text', '');
+    });
+  },
+
+  @on('willDestroyElement')
+  _teardownLogsNotice() {
+    this.$().off('click.global-notice');
   }
 });
diff --git a/app/assets/javascripts/discourse/initializers/logs-notice.js.es6 b/app/assets/javascripts/discourse/initializers/logs-notice.js.es6
new file mode 100644
index 000000000..424d84298
--- /dev/null
+++ b/app/assets/javascripts/discourse/initializers/logs-notice.js.es6
@@ -0,0 +1,18 @@
+import LogsNotice from 'discourse/services/logs-notice';
+import Singleton from 'discourse/mixins/singleton';
+
+export default {
+  name: "logs-notice",
+  after: "message-bus",
+
+  initialize: function (container) {
+    const siteSettings = container.lookup('site-settings:main');
+    const messageBus = container.lookup('message-bus:main');
+    const keyValueStore = container.lookup('key-value-store:main');
+    LogsNotice.reopenClass(Singleton, {
+      createCurrent() {
+        return this.create({ messageBus, keyValueStore, siteSettings});
+      }
+    });
+  }
+};
diff --git a/app/assets/javascripts/discourse/services/logs-notice.js.es6 b/app/assets/javascripts/discourse/services/logs-notice.js.es6
new file mode 100644
index 000000000..8948de3a1
--- /dev/null
+++ b/app/assets/javascripts/discourse/services/logs-notice.js.es6
@@ -0,0 +1,69 @@
+import { on, observes } from 'ember-addons/ember-computed-decorators';
+import computed from 'ember-addons/ember-computed-decorators';
+
+const LOGS_NOTICE_KEY = "logs-notice-text";
+
+const LogsNotice = Ember.Object.extend({
+  text: "",
+
+  @on('init')
+  _setup() {
+    if (!this.get('isActivated')) return;
+
+    const text = this.keyValueStore.getItem(LOGS_NOTICE_KEY);
+    if (text) this.set('text', text);
+
+    this.messageBus.subscribe("/logs_error_rate_exceeded", data => {
+      const duration = data.duration;
+      var siteSettingLimit = 0;
+
+      if (duration === 'minute') {
+        siteSettingLimit = this.siteSettings.alert_admins_if_errors_per_minute;
+      } else if (duration === 'hour') {
+        siteSettingLimit = this.siteSettings.alert_admins_if_errors_per_hour;
+      }
+
+      this.set('text',
+        I18n.t('logs_error_rate_exceeded_notice', {
+          timestamp: moment().format("YYYY-MM-DD H:mm:ss"),
+          siteSettingLimit: siteSettingLimit,
+          rate: data.rate,
+          duration: duration,
+          url: Discourse.getURL('/logs')
+        })
+      );
+    });
+  },
+
+  @computed('text')
+  isEmpty(text) {
+    return Ember.isEmpty(text);
+  },
+
+  @computed('text')
+  message(text) {
+    return new Handlebars.SafeString(text);
+  },
+
+  @computed('currentUser')
+  isAdmin(currentUser) {
+    return currentUser && currentUser.admin;
+  },
+
+  @computed('isEmpty', 'isAdmin')
+  hidden(isEmpty, isAdmin) {
+    return !isAdmin || isEmpty;
+  },
+
+  @observes('text')
+  _updateKeyValueStore() {
+    this.keyValueStore.setItem(LOGS_NOTICE_KEY, this.get('text'));
+  },
+
+  @computed('siteSettings.alert_admins_if_errors_per_hour', 'siteSettings.alert_admins_if_errors_per_minute')
+  isActivated(errorsPerHour, errorsPerMinute) {
+    return errorsPerHour > 0 || errorsPerMinute > 0;
+  }
+});
+
+export default LogsNotice;
diff --git a/config/initializers/100-logster.rb b/config/initializers/100-logster.rb
index 2daf7200f..80bbead41 100644
--- a/config/initializers/100-logster.rb
+++ b/config/initializers/100-logster.rb
@@ -55,3 +55,22 @@ Logster.config.current_context = lambda{|env,&blk|
 Logster.config.subdirectory = "#{GlobalSetting.relative_url_root}/logs"
 
 Logster.config.application_version = Discourse.git_version
+
+redis = Logster.store.redis
+Logster.config.redis_prefix = "#{redis.namespace}"
+Logster.config.redis_raw_connection = redis.without_namespace
+
+%w{minute hour}.each do |duration|
+  site_setting_error_rate = SiteSetting.public_send("alert_admins_if_errors_per_#{duration}")
+
+  if site_setting_error_rate > 0
+    Logster.store.public_send(
+      "register_rate_limit_per_#{duration}",
+      [Logger::WARN, Logger::ERROR, Logger::FATAL, Logger::UNKNOWN],
+      site_setting_error_rate
+    ) do |rate|
+
+      MessageBus.publish("/logs_error_rate_exceeded", { rate: rate, duration: duration })
+    end
+  end
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 8e434f3bd..0eea80d83 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -779,6 +779,7 @@ en:
     too_few_topics_and_posts_notice: "Let's <a href='http://blog.discourse.org/2014/08/building-a-discourse-community/'>get this discussion started!</a> There are currently <strong>%{currentTopics} / %{requiredTopics}</strong> topics and <strong>%{currentPosts} / %{requiredPosts}</strong> posts. New visitors need some conversations to read and respond to."
     too_few_topics_notice: "Let's <a href='http://blog.discourse.org/2014/08/building-a-discourse-community/'>get this discussion started!</a> There are currently <strong>%{currentTopics} / %{requiredTopics}</strong> topics. New visitors need some conversations to read and respond to."
     too_few_posts_notice: "Let's <a href='http://blog.discourse.org/2014/08/building-a-discourse-community/'>get this discussion started!</a> There are currently <strong>%{currentPosts} / %{requiredPosts}</strong> posts. New visitors need some conversations to read and respond to."
+    logs_error_rate_exceeded_notice: "%{timestamp}: Current rate of <a href='%{url}' target='_blank'>%{rate} errors/%{duration}</a> has exceeded site settings's limit of %{siteSettingLimit} errors/%{duration}."
 
     learn_more: "learn more..."
 
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 85ac95eb2..5c5d98e35 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -977,6 +977,9 @@ en:
     max_invites_per_day: "Maximum number of invites a user can send per day."
     max_topic_invitations_per_day: "Maximum number of topic invitations a user can send per day."
 
+    alert_admins_if_errors_per_minute: "Number of errors per minute in order to trigger an admin alert. A value of 0 disables this feature. NOTE: requires restart."
+    alert_admins_if_errors_per_hour: "Number of errors per hour in order to trigger an admin alert. A value of 0 disables this feature. NOTE: requires restart."
+
     suggested_topics: "Number of suggested topics shown at the bottom of a topic."
     limit_suggested_to_category: "Only show topics from the current category in suggested topics."
 
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 64990a3e2..069ffe405 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -761,6 +761,14 @@ rate_limits:
   tl2_additional_likes_per_day_multiplier: 1.5
   tl3_additional_likes_per_day_multiplier: 2
   tl4_additional_likes_per_day_multiplier: 3
+  alert_admins_if_errors_per_minute:
+    client: true
+    shadowed_by_global: true
+    default: 0
+  alert_admins_if_errors_per_hour:
+    client: true
+    shadowed_by_global: true
+    default: 0
 
 developer:
   force_hostname: