diff --git a/app/assets/javascripts/admin/controllers/admin-logs-search-logs.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-search-logs.js.es6
new file mode 100644
index 00000000000..1de32762a6a
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin-logs-search-logs.js.es6
@@ -0,0 +1,4 @@
+export default Ember.Controller.extend({
+ loading: false,
+ period: "all"
+});
diff --git a/app/assets/javascripts/admin/routes/admin-logs-search-logs.js.es6 b/app/assets/javascripts/admin/routes/admin-logs-search-logs.js.es6
new file mode 100644
index 00000000000..013bdb4655a
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin-logs-search-logs.js.es6
@@ -0,0 +1,25 @@
+import { ajax } from 'discourse/lib/ajax';
+
+export default Discourse.Route.extend({
+ renderTemplate() {
+ this.render('admin/templates/logs/search-logs', {into: 'adminLogs'});
+ },
+
+ queryParams: {
+ period: {
+ refreshModel: true
+ }
+ },
+
+ model(params) {
+ this._params = params;
+ return ajax('/admin/logs/search_logs.json', { data: { period: params.period } }).then(search_logs => {
+ return search_logs.map(sl => Ember.Object.create(sl));
+ });
+ },
+
+ setupController(controller, model) {
+ const params = this._params;
+ controller.setProperties({ model, period: params.period });
+ }
+});
diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
index 7628ca83895..b221ba639c1 100644
--- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6
+++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
@@ -66,6 +66,7 @@ export default function() {
this.route('screenedEmails', { path: '/screened_emails' });
this.route('screenedIpAddresses', { path: '/screened_ip_addresses' });
this.route('screenedUrls', { path: '/screened_urls' });
+ this.route('searchLogs', { path: '/search_logs' });
this.route('adminWatchedWords', { path: '/watched_words', resetNamespace: true}, function() {
this.route('index', { path: '/' } );
this.route('action', { path: '/action/:action_id' } );
diff --git a/app/assets/javascripts/admin/templates/logs.hbs b/app/assets/javascripts/admin/templates/logs.hbs
index d72e5a92622..362c3767699 100644
--- a/app/assets/javascripts/admin/templates/logs.hbs
+++ b/app/assets/javascripts/admin/templates/logs.hbs
@@ -4,6 +4,7 @@
{{nav-item route='adminLogs.screenedIpAddresses' label='admin.logs.screened_ips.title'}}
{{nav-item route='adminLogs.screenedUrls' label='admin.logs.screened_urls.title'}}
{{nav-item route='adminWatchedWords' label='admin.watched_words.title'}}
+ {{nav-item route='adminLogs.searchLogs' label='admin.logs.search_logs.title'}}
{{#if currentUser.admin}}
{{nav-item path='/logs' label='admin.logs.logster.title'}}
{{/if}}
diff --git a/app/assets/javascripts/admin/templates/logs/search-logs.hbs b/app/assets/javascripts/admin/templates/logs/search-logs.hbs
new file mode 100644
index 00000000000..09db225a13d
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/logs/search-logs.hbs
@@ -0,0 +1,36 @@
+
+ {{period-chooser period=period}}
+
+
+
+{{#conditional-loading-spinner condition=loading}}
+ {{#if model.length}}
+
+
+
+
{{i18n 'admin.logs.search_logs.term'}}
+
{{i18n 'admin.logs.search_logs.searches'}}
+
{{i18n 'admin.logs.search_logs.click_through'}}
+
{{i18n 'admin.logs.search_logs.most_viewed_topic'}}
+
{{i18n 'admin.logs.search_logs.unique'}}
+
+
+ {{#each model as |item|}}
+
+
{{item.term}}
+
{{item.searches}}
+
{{item.click_through}}
+
+
{{item.unique}}
+
+ {{/each}}
+
+
+ {{else}}
+ {{i18n 'search.no_results'}}
+ {{/if}}
+{{/conditional-loading-spinner}}
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index 838b5c6c0a4..d21ce2d81f6 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -1328,7 +1328,7 @@ table.api-keys {
position: absolute;
}
-.staff-actions, .screened-emails, .screened-urls, .screened-ip-addresses, .permalinks, .web-hook-events {
+.staff-actions, .screened-emails, .screened-urls, .screened-ip-addresses, .permalinks, .search-logs-list, .web-hook-events {
border-bottom: dotted 1px dark-light-choose($primary-low-mid, $secondary);
@@ -1354,6 +1354,23 @@ table.api-keys {
}
}
+.search-logs-list{
+ .col {
+ text-align: center;
+ width: 10%;
+ }
+
+ .col.term {
+ width: 30%;
+ }
+
+ .col.topic {
+ width: 35%;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
.log-details-modal {
.modal-tab {
width: 95%;
diff --git a/app/controllers/admin/search_logs_controller.rb b/app/controllers/admin/search_logs_controller.rb
new file mode 100644
index 00000000000..a7981eb7383
--- /dev/null
+++ b/app/controllers/admin/search_logs_controller.rb
@@ -0,0 +1,8 @@
+class Admin::SearchLogsController < Admin::AdminController
+
+ def index
+ period = params[:period] || "all"
+ render_serialized(SearchLog.trending(period.to_sym), SearchLogsSerializer)
+ end
+
+end
diff --git a/app/models/search_log.rb b/app/models/search_log.rb
index acaf269c563..7c0ed844d4f 100644
--- a/app/models/search_log.rb
+++ b/app/models/search_log.rb
@@ -1,6 +1,7 @@
require_dependency 'enum'
class SearchLog < ActiveRecord::Base
+ belongs_to :topic, foreign_key: :clicked_topic_id
validates_presence_of :term, :ip_address
def self.search_types
@@ -48,6 +49,32 @@ class SearchLog < ActiveRecord::Base
end
end
+ def self.trending(period = :all)
+ SearchLog.select("term,
+ COUNT(*) AS searches,
+ SUM(CASE
+ WHEN clicked_topic_id IS NOT NULL THEN 1
+ ELSE 0
+ END) AS click_through,
+ MODE() WITHIN GROUP (ORDER BY clicked_topic_id) AS clicked_topic_id,
+ COUNT(DISTINCT ip_address) AS unique")
+ .where('created_at > ?', start_of(period))
+ .group(:term)
+ .order('COUNT(*) DESC')
+ .limit(100).to_a
+ end
+
+ def self.start_of(period)
+ case period
+ when :yearly then 1.year.ago
+ when :monthly then 1.month.ago
+ when :quarterly then 3.months.ago
+ when :weekly then 1.week.ago
+ when :daily then 1.day.ago
+ else 1000.years.ago
+ end
+ end
+
def self.clean_up
search_id = SearchLog.order(:id).offset(SiteSetting.search_query_log_max_size).limit(1).pluck(:id)
if search_id.present?
diff --git a/app/serializers/search_logs_serializer.rb b/app/serializers/search_logs_serializer.rb
new file mode 100644
index 00000000000..434ef5dec9c
--- /dev/null
+++ b/app/serializers/search_logs_serializer.rb
@@ -0,0 +1,17 @@
+class SearchLogsSerializer < ApplicationSerializer
+ attributes :term,
+ :searches,
+ :click_through,
+ :clicked_topic_id,
+ :topic_title,
+ :topic_url,
+ :unique
+
+ def topic_title
+ object&.topic&.title
+ end
+
+ def topic_url
+ object&.topic&.url
+ end
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 1ec3d868816..9ff7234a5ad 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -3184,6 +3184,13 @@ en:
roll_up:
text: "Roll up"
title: "Creates new subnet ban entries if there are at least 'min_ban_entries_for_roll_up' entries."
+ search_logs:
+ title: "Search Logs"
+ term: "Term"
+ searches: "Searches"
+ click_through: "Click Through"
+ most_viewed_topic: "Most Viewed Topic"
+ unique: "Unique"
logster:
title: "Error Logs"
diff --git a/config/routes.rb b/config/routes.rb
index b9cd96d4963..776a61264e2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -174,6 +174,7 @@ Discourse::Application.routes.draw do
end
end
post "watched_words/upload" => "watched_words#upload"
+ resources :search_logs, only: [:index]
end
get "/logs" => "staff_action_logs#index"
diff --git a/spec/models/search_log_spec.rb b/spec/models/search_log_spec.rb
index 34dabb0f38c..3bc2f39106b 100644
--- a/spec/models/search_log_spec.rb
+++ b/spec/models/search_log_spec.rb
@@ -156,6 +156,41 @@ RSpec.describe SearchLog, type: :model do
end
end
+ context "trending" do
+ before do
+ SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.1')
+ SearchLog.log(term: 'php', search_type: :header, ip_address: '127.0.0.1')
+ SearchLog.log(term: 'java', search_type: :header, ip_address: '127.0.0.1')
+ SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.1', user_id: Fabricate(:user).id)
+ SearchLog.log(term: 'swift', search_type: :header, ip_address: '127.0.0.1')
+ SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.2')
+ end
+
+ it "considers time period" do
+ expect(SearchLog.trending.count).to eq(4)
+
+ SearchLog.where(term: 'swift').update_all(created_at: 1.year.ago)
+ expect(SearchLog.trending(:monthly).count).to eq(3)
+ end
+
+ it "correctly returns trending data" do
+ top_trending = SearchLog.trending.first
+ expect(top_trending.term).to eq("ruby")
+ expect(top_trending.searches).to eq(3)
+ expect(top_trending.unique).to eq(2)
+ expect(top_trending.click_through).to eq(0)
+ expect(top_trending.clicked_topic_id).to eq(nil)
+
+ popular_topic = Fabricate(:topic)
+ not_so_popular_topic = Fabricate(:topic)
+ SearchLog.where(term: 'ruby', ip_address: '127.0.0.1').update_all(clicked_topic_id: popular_topic.id)
+ SearchLog.where(term: 'ruby', ip_address: '127.0.0.2').update_all(clicked_topic_id: not_so_popular_topic.id)
+ top_trending = SearchLog.trending.first
+ expect(top_trending.click_through).to eq(3)
+ expect(top_trending.clicked_topic_id).to eq(popular_topic.id)
+ end
+ end
+
context "clean_up" do
it "will remove old logs" do
diff --git a/spec/requests/admin/search_logs_spec.rb b/spec/requests/admin/search_logs_spec.rb
new file mode 100644
index 00000000000..310931b35ec
--- /dev/null
+++ b/spec/requests/admin/search_logs_spec.rb
@@ -0,0 +1,35 @@
+require 'rails_helper'
+
+RSpec.describe Admin::SearchLogsController do
+ let(:admin) { Fabricate(:admin) }
+ let(:user) { Fabricate(:user) }
+
+ before do
+ SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.1')
+ end
+
+ context "#index" do
+ it "raises an error if you aren't logged in" do
+ expect do
+ get '/admin/logs/search_logs.json'
+ end.to raise_error(ActionController::RoutingError)
+ end
+
+ it "raises an error if you aren't an admin" do
+ sign_in(user)
+ expect do
+ get '/admin/logs/search_logs.json'
+ end.to raise_error(ActionController::RoutingError)
+ end
+
+ it "should work if you are an admin" do
+ sign_in(admin)
+ get '/admin/logs/search_logs.json'
+
+ expect(response).to be_success
+
+ json = ::JSON.parse(response.body)
+ expect(json[0]['term']).to eq('ruby')
+ end
+ end
+end
diff --git a/test/javascripts/acceptance/admin-search-logs-test.js.es6 b/test/javascripts/acceptance/admin-search-logs-test.js.es6
new file mode 100644
index 00000000000..e1e7ed5355a
--- /dev/null
+++ b/test/javascripts/acceptance/admin-search-logs-test.js.es6
@@ -0,0 +1,10 @@
+import { acceptance } from "helpers/qunit-helpers";
+acceptance("Admin - Search Logs", { loggedIn: true });
+
+QUnit.test("show search logs", assert => {
+ visit("/admin/logs/search_logs");
+ andThen(() => {
+ assert.ok($('div.table.search-logs-list').length, "has the div class");
+ assert.ok(exists('.search-logs-list .admin-list-item .col'), "has a list of search logs");
+ });
+});
diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6
index 60c57629055..81539e88dfb 100644
--- a/test/javascripts/helpers/create-pretender.js.es6
+++ b/test/javascripts/helpers/create-pretender.js.es6
@@ -391,6 +391,12 @@ export default function() {
return response(200, result);
});
+ this.get('/admin/logs/search_logs.json', () => {
+ return response(200, [
+ {"term":"foobar","searches":35,"click_through":6,"clicked_topic_id":1550,"topic_title":"Foo Bar Topic Title","topic_url":"http://discourse.example.com/t/foo-bar-topic-title/1550","unique":16}
+ ]);
+ });
+
this.get('/onebox', request => {
if (request.queryParams.url === 'http://www.example.com/has-title.html' ||
request.queryParams.url === 'http://www.example.com/has-title-and-a-url-that-is-more-than-80-characters-because-thats-good-for-seo-i-guess.html') {