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}}
+
+ {{#if item.clicked_topic_id}} + {{item.topic_title}} + {{/if}} +
+
{{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') {