From c974f55e5e363b6066b31d584bd77a357c07ada6 Mon Sep 17 00:00:00 2001 From: Sergey Markelov Date: Fri, 4 Dec 2015 15:22:58 -0800 Subject: [PATCH 1/2] Added Export CSV = Idea = Quiet often while working with Redis the user might feel a need to have counters per event. Events could obey some hierarchy, i.e. user/1/newPosts user/2/newPosts ... user/n/newPosts user/1/to-user/2/messages user/3/to-user/1/messages Would be useful if redis-browser could somehow represent the data from all of these keys in some kind of a table view. = Realization = MS Excel, OS X Numbers, Libreoffice alternative - these are all good examples of software which can pivot, sort, filter, slice and dice table data (even hierarchial data). This commit addresses an Export to CSV format to be consumed by such apps. = Implementation = "Export CSV" is an extra button in the top-right corner of the header. When clicked, opens a form similar to "Configure Redis Connection" form. From there the user is able to choose: * Include keys - keys to include in the output as per Redis KEYS man. * Exclude keys pattern - regex which excludes the keys from the output. * Filename - a file with that name will be downloaded if export was good. Only Redis String datatype is supported for exported keys for a reason - the keys and values are extracted to be opened in MS Excel, Libreoffice alternative or OS X Numbers. Keys are broken down in separate CSV fields the same way the keys are broken down into a hierarchy view on the lefthand side of the UI - by delimiter [:/] = Configuration = "Exclude keys pattern" comes from config.yml. A connection now has an optional parameter - exclude_pattern. Example exclude_pattern: "^.+/total/.+$" "Include keys" - if no key is selected from the tree on the left hand side - "*", otherwise - the selected key. --- README.md | 1 + config.yml | 1 + lib/redis-browser.rb | 6 ++- lib/redis-browser/browser.rb | 21 ++++++++++ lib/redis-browser/public/css/app.css | 7 +++- lib/redis-browser/templates/coffee/app.coffee | 36 ++++++++++++++++ lib/redis-browser/templates/index.slim | 41 ++++++++++++++++++- lib/redis-browser/web.rb | 11 +++++ 8 files changed, 121 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1830f40..b417ffb 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ * Search keys * Can be mounted to Rails applications as engine * Can connect to multiple databases +* Export keys as CSV ## Installation diff --git a/config.yml b/config.yml index bfc2b12..c34f380 100644 --- a/config.yml +++ b/config.yml @@ -1,6 +1,7 @@ connections: localhost: url: redis://127.0.0.1:6379/0 + exclude_pattern: "^.+/total/.+$" other: host: 127.0.0.1 port: 6379 diff --git a/lib/redis-browser.rb b/lib/redis-browser.rb index cae8ec7..abe0f24 100644 --- a/lib/redis-browser.rb +++ b/lib/redis-browser.rb @@ -1,3 +1,4 @@ +require 'csv' require 'sinatra/base' require 'multi_json' require 'sinatra/json' @@ -11,7 +12,10 @@ module RedisBrowser DEFAULTS = { 'connections' => { - 'default' => 'redis://127.0.0.1:6379/0' + 'default' => { + 'url' => 'redis://127.0.0.1:6379/0', + 'exclude-pattern' => '' + } } } diff --git a/lib/redis-browser/browser.rb b/lib/redis-browser/browser.rb index c3c5093..38b7af0 100644 --- a/lib/redis-browser/browser.rb +++ b/lib/redis-browser/browser.rb @@ -125,6 +125,27 @@ def get(key, opts = {}) }.merge(data) end + def exportCSV(include_pattern, exclude_pattern) + key = include_pattern + key << "*" unless key.end_with?("*") + exclude_rx = Regexp.new(exclude_pattern) + keys = redis.keys(key).select { |k| exclude_rx.match(k).nil? } + values = redis.multi do |multi| + keys.each { |k| multi.get(k) } + end + kv = keys.zip(values) + + csv_string = CSV.generate do |csv| + kv.each do |k, v| + line = k.split(/[:\/]/) << v + csv << line + end + end + csv_string + rescue => ex + {:error => ex.message} + end + def ping redis.ping == "PONG" {:ok => 1} diff --git a/lib/redis-browser/public/css/app.css b/lib/redis-browser/public/css/app.css index 2fcf050..3802d38 100644 --- a/lib/redis-browser/public/css/app.css +++ b/lib/redis-browser/public/css/app.css @@ -34,11 +34,16 @@ body { margin-right: 5px; } +.help-block { + font-size: 0.9em; + color: gray; +} + .value-list-index { width: 30px; } .value-zset-score { width: 30px; } .per-page { margin-top: 25px; } -.connection-info { padding-right: 20px; } +.header-item { padding-right: 20px; } .keys-search { width: 100px; } #http-loader { diff --git a/lib/redis-browser/templates/coffee/app.coffee b/lib/redis-browser/templates/coffee/app.coffee index 0ad1561..8a8f793 100644 --- a/lib/redis-browser/templates/coffee/app.coffee +++ b/lib/redis-browser/templates/coffee/app.coffee @@ -22,6 +22,10 @@ app.factory 'API', ['$http', ($http) -> (connection) -> ps = {connection: connection} { + exportCSV: (params) -> $http.get("#{jsEnv.root_path}exportCSV", { + params: angular.extend({}, ps, params) + }).then (e) -> e, + ping: () -> $http.get("#{jsEnv.root_path}ping.json", { params: ps }).then (e) -> e.data, @@ -97,6 +101,38 @@ app.factory 'API', ['$http', ($http) -> backdropFade: true dialogFade: true + $scope.export = + filename: "redis-dump.csv" + + open: -> + $scope.export.show = true + $scope.export.include = $scope.key.full || "*" + $scope.export.exclude = $scope.connections[$scope.config.connection].exclude_pattern + + close: -> + $scope.export.show = false + + run: -> + $scope.export.error = null + + $scope.api.exportCSV( + include: $scope.export.include, + exclude: $scope.export.exclude + ).then (response) -> + content = response.data + if response.headers('Content-Type').includes("text/csv") + hiddenElement = document.createElement('a') + + hiddenElement.href = 'data:attachment/csv,' + encodeURI(content) + hiddenElement.target = '_blank' + hiddenElement.download = $scope.export.filename + hiddenElement.click() + + $scope.config.close() + + else + $scope.export.error = content.error + $scope.api = API($scope.config.connection) diff --git a/lib/redis-browser/templates/index.slim b/lib/redis-browser/templates/index.slim index 7d21d86..2d8c44a 100644 --- a/lib/redis-browser/templates/index.slim +++ b/lib/redis-browser/templates/index.slim @@ -48,6 +48,43 @@ html ng-app="browser" ' {{ config.error }} button.btn.btn-success type="submit" ng-click="config.save()" Save + div modal="export.show" close="export.close()" options="config.modalOpts" + .form-horizontal + .modal-header + button.close type="button" ng-click="export.close()" × + h4 Export CSV + .modal-body + .control-group + label.control-label for="export-include" Include keys + .controls + input.form-control id="export-include" ng-model="export.include" ng-required="required" type="text" + + .control-group + label.control-label for="export-exclude" Exclude keys pattern + .controls + input.form-control id="export-exclude" ng-model="export.exclude" type="text" + + .control-group + label.control-label for="export-filename" File name + .controls + input.form-control id="export-filename" ng-model="export.filename" ng-required="required" type="text" + + span.help-block + b Include keys + | is keys to include in the output as per + a href="http://redis.io/commands/KEYS" target="_blank" KEYS + | . + br + b Exclude keys pattern + | is a + a href="https://en.wikipedia.org/wiki/Regular_expression" target="_blank" regex + | which excludes the keys from the output. + + .modal-footer + span.alert.alert-error.pull-left ng-show="export.error" + ' {{ export.error }} + button.btn.btn-success type="submit" ng-click="export.run()" Export + .navbar.navbar-inverse.navbar-fixed-top .navbar-inner .container-fluid @@ -58,8 +95,10 @@ html ng-app="browser" .nav-collapse.collapse p.pull-right + button.btn.btn-default ng-click="export.open()" Export CSV + p.pull-right.header-item button.btn.btn-success ng-click="config.open()" Configure - p.navbar-text.pull-right.connection-info + p.navbar-text.pull-right.header-item ' Connected to strong ' {{ config.connection }} diff --git a/lib/redis-browser/web.rb b/lib/redis-browser/web.rb index 0ca5541..21402d1 100644 --- a/lib/redis-browser/web.rb +++ b/lib/redis-browser/web.rb @@ -34,6 +34,17 @@ class Web < Sinatra::Base slim :index end + get '/exportCSV' do + result = browser.exportCSV(params[:include], params[:exclude]) + if result.is_a? String + content_type 'text/csv' + attachment "redis-dump.csv" + result + else + json result + end + end + get '/ping.json' do json browser.ping end From 83fcb92a541820b6d7c474c342d310c05897aca7 Mon Sep 17 00:00:00 2001 From: Sergey Markelov Date: Mon, 14 Dec 2015 11:43:36 -0800 Subject: [PATCH 2/2] Lower-case 'export-csv' route Remove hiddenElement after 5 seconds Hide $scope.export.error on form close --- lib/redis-browser/templates/coffee/app.coffee | 13 ++++++++++--- lib/redis-browser/web.rb | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/redis-browser/templates/coffee/app.coffee b/lib/redis-browser/templates/coffee/app.coffee index 8a8f793..7960516 100644 --- a/lib/redis-browser/templates/coffee/app.coffee +++ b/lib/redis-browser/templates/coffee/app.coffee @@ -22,7 +22,7 @@ app.factory 'API', ['$http', ($http) -> (connection) -> ps = {connection: connection} { - exportCSV: (params) -> $http.get("#{jsEnv.root_path}exportCSV", { + exportCSV: (params) -> $http.get("#{jsEnv.root_path}export-csv", { params: angular.extend({}, ps, params) }).then (e) -> e, @@ -111,14 +111,15 @@ app.factory 'API', ['$http', ($http) -> close: -> $scope.export.show = false - - run: -> $scope.export.error = null + run: -> $scope.api.exportCSV( include: $scope.export.include, exclude: $scope.export.exclude ).then (response) -> + $scope.export.error = null + content = response.data if response.headers('Content-Type').includes("text/csv") hiddenElement = document.createElement('a') @@ -130,6 +131,12 @@ app.factory 'API', ['$http', ($http) -> $scope.config.close() + destroyA = setInterval( -> + hiddenElement.remove() + clearInterval(destroyA) + , 5000 + ) + else $scope.export.error = content.error diff --git a/lib/redis-browser/web.rb b/lib/redis-browser/web.rb index 21402d1..b8d5217 100644 --- a/lib/redis-browser/web.rb +++ b/lib/redis-browser/web.rb @@ -34,7 +34,7 @@ class Web < Sinatra::Base slim :index end - get '/exportCSV' do + get '/export-csv' do result = browser.exportCSV(params[:include], params[:exclude]) if result.is_a? String content_type 'text/csv'