Commit c03373c6 by Rashid Khan

linting and added filter states

parent 254a553e
......@@ -12,9 +12,9 @@ var modules = [
'$strap.directives',
'kibana.panels',
'ngSanitize',
]
];
var scripts = []
var scripts = [];
var labjs = $LAB
.script("common/lib/jquery-1.8.0.min.js").wait()
......@@ -34,12 +34,12 @@ var labjs = $LAB
.script("js/controllers.js")
.script("js/filters.js")
.script("js/directives.js")
.script("js/panels.js").wait()
.script("js/panels.js").wait();
_.each(config.modules, function(v) {
labjs = labjs.script('panels/'+v+'/module.js')
modules.push('kibana.'+v)
})
labjs = labjs.script('panels/'+v+'/module.js');
modules.push('kibana.'+v);
});
/* Application level module which depends on filters, controllers, and services */
labjs.wait(function(){
......@@ -59,7 +59,7 @@ labjs.wait(function(){
});
}]);
angular.element(document).ready(function() {
$('body').attr('ng-controller', 'DashCtrl')
$('body').attr('ng-controller', 'DashCtrl');
angular.bootstrap(document, ['kibana']);
});
});
......@@ -11,7 +11,7 @@ angular.module('kibana.controllers', [])
editable: true,
rows: [],
last: null
}
};
$scope.init = function() {
......@@ -22,16 +22,16 @@ angular.module('kibana.controllers', [])
$scope.dashboard = dashboard;
// Provide a global list of all see fields
$scope.fields = fields
$scope.fields = fields;
$scope.reset_row();
$scope.clear_all_alerts();
var ejs = $scope.ejs = ejsResource(config.elasticsearch);
}
};
$scope.add_row = function(dash,row) {
dash.rows.push(row);
}
};
$scope.reset_row = function() {
$scope.row = {
......@@ -42,8 +42,8 @@ angular.module('kibana.controllers', [])
};
$scope.row_style = function(row) {
return { 'min-height': row.collapse ? '5px' : row.height }
}
return { 'min-height': row.collapse ? '5px' : row.height };
};
$scope.alert = function(title,text,severity,timeout) {
var alert = {
......@@ -52,30 +52,34 @@ angular.module('kibana.controllers', [])
severity: severity || 'info',
};
$scope.global_alert.push(alert);
if (timeout > 0)
if (timeout > 0) {
$timeout(function() {
$scope.global_alert = _.without($scope.global_alert,alert)
$scope.global_alert = _.without($scope.global_alert,alert);
}, timeout);
}
}
};
$scope.clear_alert = function(alert) {
$scope.global_alert = _.without($scope.global_alert,alert);
}
};
$scope.clear_all_alerts = function() {
$scope.global_alert = []
}
$scope.global_alert = [];
};
$scope.edit_path = function(type) {
if(type)
if(type) {
return 'panels/'+type+'/editor.html';
}
} else {
return false;
}
};
// This is whoafully incomplete, but will do for now
$scope.parse_error = function(data) {
var _error = data.match("nested: (.*?);")
var _error = data.match("nested: (.*?);");
return _.isNull(_error) ? data : _error[1];
}
};
$scope.init();
......@@ -89,31 +93,32 @@ angular.module('kibana.controllers', [])
collapsable: true,
editable: true,
panels: [],
}
_.defaults($scope.row,_d)
};
_.defaults($scope.row,_d);
$scope.init = function() {
$scope.reset_panel();
}
};
$scope.toggle_row = function(row) {
row.collapse = row.collapse ? false : true;
if (!row.collapse) {
$timeout(function() {
$scope.$broadcast('render')
$scope.$broadcast('render');
});
}
}
};
// This can be overridden by individual panel
// This can be overridden by individual panels
$scope.close_edit = function() {
$scope.$broadcast('render')
}
$scope.$broadcast('render');
};
$scope.add_panel = function(row,panel) {
$scope.row.panels.push(panel);
}
};
$scope.reset_panel = function() {
$scope.panel = {
......@@ -121,7 +126,7 @@ angular.module('kibana.controllers', [])
error : false,
span : 3,
editable: true,
group : ['default'],
group : ['default']
};
};
......
......@@ -24,7 +24,7 @@ angular.module('kibana.directives', [])
var template = '<div ng-controller="'+scope.panel.type+'" ng-include src="\''+scope.edit_path(scope.panel.type)+'\'"></div>';
elem.html($compile(angular.element(template))(scope));
}
})
});
}
};
})
......@@ -39,11 +39,13 @@ angular.module('kibana.directives', [])
}
function join_array(text) {
if(_.isArray(text))
if(_.isArray(text)) {
return (text || '').join(',');
else
return text
} else {
return text;
}
}
ngModel.$parsers.push(split_array);
ngModel.$formatters.push(join_array);
}
......@@ -54,8 +56,10 @@ angular.module('kibana.directives', [])
restrict: 'A',
require: 'ngModel',
link: function(scope, elm, attr, ngModelCtrl) {
if (attr.type === 'radio' || attr.type === 'checkbox') return;
if (attr.type === 'radio' || attr.type === 'checkbox') {
return;
}
elm.unbind('input').unbind('keydown').unbind('change');
elm.bind('blur', function() {
scope.$apply(function() {
......@@ -65,5 +69,4 @@ angular.module('kibana.directives', [])
}
};
});
;
......@@ -6,5 +6,5 @@ angular.module('kibana.filters', [])
.filter('stringSort', function() {
return function(input) {
return input.sort();
}
};
});
\ No newline at end of file
......@@ -2,4 +2,4 @@
/*global angular:true */
'use strict';
angular.module('kibana.panels', [])
angular.module('kibana.panels', []);
/*jshint globalstrict:true */
/*global angular:true */
/*global L:false*/
/*
## Better maps
......@@ -14,6 +17,8 @@
* spyable :: Show the 'eye' icon that reveals the last ES query
*/
'use strict';
angular.module('kibana.bettermap', [])
.controller('bettermap', function($scope, query, dashboard, filterSrv) {
......@@ -26,61 +31,61 @@ angular.module('kibana.bettermap', [])
tooltip : "_id",
field : null,
group : "default"
}
_.defaults($scope.panel,_d)
};
_.defaults($scope.panel,_d);
$scope.init = function() {
$scope.$on('refresh',function(){
$scope.get_data();
})
});
$scope.get_data();
}
};
$scope.get_data = function(segment,query_id) {
$scope.panel.error = false;
// Make sure we have everything for the request to complete
if(dashboard.indices.length == 0) {
if(dashboard.indices.length === 0) {
return;
}
if(_.isUndefined($scope.panel.field)) {
$scope.panel.error = "Please select a field that contains geo point in [lon,lat] format"
return
$scope.panel.error = "Please select a field that contains geo point in [lon,lat] format";
return;
}
// Determine the field to sort on
var timeField = _.uniq(_.pluck(filterSrv.getByType('time'),'field'))
var timeField = _.uniq(_.pluck(filterSrv.getByType('time'),'field'));
if(timeField.length > 1) {
$scope.panel.error = "Time field must be consistent amongst time filters"
} else if(timeField.length == 0) {
$scope.panel.error = "Time field must be consistent amongst time filters";
} else if(timeField.length === 0) {
timeField = null;
} else {
timeField = timeField[0]
timeField = timeField[0];
}
var _segment = _.isUndefined(segment) ? 0 : segment
var _segment = _.isUndefined(segment) ? 0 : segment;
var boolQuery = ejs.BoolQuery();
var boolQuery = $scope.ejs.BoolQuery();
_.each(query.list,function(q) {
boolQuery = boolQuery.should(ejs.QueryStringQuery((q.query || '*')))
})
boolQuery = boolQuery.should($scope.ejs.QueryStringQuery((q.query || '*')));
});
var request = $scope.ejs.Request().indices(dashboard.indices[_segment])
.query(ejs.FilteredQuery(
.query($scope.ejs.FilteredQuery(
boolQuery,
filterSrv.getBoolFilter(filterSrv.ids).must(ejs.ExistsFilter($scope.panel.field))
filterSrv.getBoolFilter(filterSrv.ids).must($scope.ejs.ExistsFilter($scope.panel.field))
))
.fields([$scope.panel.field,$scope.panel.tooltip])
.size($scope.panel.size)
.size($scope.panel.size);
if(!_.isNull(timeField)) {
request = request.sort(timeField,'desc');
}
$scope.populate_modal(request)
$scope.populate_modal(request);
var results = request.doSearch()
var results = request.doSearch();
// Populate scope when we have results
results.then(function(results) {
......@@ -89,7 +94,7 @@ angular.module('kibana.bettermap', [])
if(_segment === 0) {
$scope.hits = 0;
$scope.data = [];
query_id = $scope.query_id = new Date().getTime()
query_id = $scope.query_id = new Date().getTime();
}
// Check for error and abort if found
......@@ -101,31 +106,32 @@ angular.module('kibana.bettermap', [])
// Check that we're still on the same query, if not stop
if($scope.query_id === query_id) {
var scripts = $LAB.script("panels/bettermap/lib/leaflet.js").wait()
var scripts = $LAB.script("panels/bettermap/lib/leaflet.js").wait();
scripts.wait(function(){
$scope.data = $scope.data.concat(_.map(results.hits.hits, function(hit) {
return {
coordinates : new L.LatLng(hit.fields[$scope.panel.field][1],hit.fields[$scope.panel.field][0]),
tooltip : hit.fields[$scope.panel.tooltip]
}
};
}));
});
// Keep only what we need for the set
$scope.data = $scope.data.slice(0,$scope.panel.size)
$scope.data = $scope.data.slice(0,$scope.panel.size);
} else {
return;
}
$scope.$emit('draw')
$scope.$emit('draw');
// Get $size results then stop querying
if($scope.data.length < $scope.panel.size && _segment+1 < dashboard.indices.length)
$scope.get_data(_segment+1,$scope.query_id)
if($scope.data.length < $scope.panel.size && _segment+1 < dashboard.indices.length) {
$scope.get_data(_segment+1,$scope.query_id);
}
});
}
};
// I really don't like this function, too much dom manip. Break out into directive?
$scope.populate_modal = function(request) {
......@@ -135,8 +141,8 @@ angular.module('kibana.bettermap', [])
'curl -XGET '+config.elasticsearch+'/'+dashboard.indices+"/_search?pretty -d'\n"+
angular.toJson(JSON.parse(request.toString()),true)+
"'</pre>",
}
}
};
};
})
.directive('bettermap', function() {
......@@ -144,7 +150,7 @@ angular.module('kibana.bettermap', [])
restrict: 'A',
link: function(scope, elem, attrs) {
elem.html('<center><img src="common/img/load_big.gif"></center>')
elem.html('<center><img src="common/img/load_big.gif"></center>');
// Receive render events
scope.$on('draw',function(){
......@@ -154,9 +160,9 @@ angular.module('kibana.bettermap', [])
scope.$on('render', function(){
if(!_.isUndefined(map)) {
map.invalidateSize();
var panes = map.getPanes()
var panes = map.getPanes();
}
})
});
var map, markers, layerGroup, mcg;
......@@ -164,7 +170,7 @@ angular.module('kibana.bettermap', [])
scope.panel.loading = false;
var scripts = $LAB.script("panels/bettermap/lib/leaflet.js").wait()
.script("panels/bettermap/lib/plugins.js")
.script("panels/bettermap/lib/plugins.js");
//add markers dynamically
scripts.wait(function(){
......@@ -185,16 +191,17 @@ angular.module('kibana.bettermap', [])
}
_.each(scope.data, function(p) {
if(!_.isUndefined(p.tooltip) && p.tooltip !== '')
layerGroup.addLayer(L.marker(p.coordinates).bindLabel(p.tooltip))
else
layerGroup.addLayer(L.marker(p.coordinates))
})
if(!_.isUndefined(p.tooltip) && p.tooltip !== '') {
layerGroup.addLayer(L.marker(p.coordinates).bindLabel(p.tooltip));
} else {
layerGroup.addLayer(L.marker(p.coordinates));
}
});
layerGroup.addTo(map)
layerGroup.addTo(map);
map.fitBounds(_.pluck(scope.data,'coordinates'));
})
});
}
}
};
......
/*jshint globalstrict:true */
/*global angular:true */
/*
## Column
......@@ -8,26 +10,23 @@
### Parameters
* panels :: an array of panel objects. All of their spans should be set to 12
### Group Events
#### Sends
* time :: Object Includes from, to and index
*/
'use strict';
angular.module('kibana.column', [])
.controller('column', function($scope, $rootScope) {
.controller('column', function($scope, $rootScope, $timeout) {
// Set and populate defaults
var _d = {
status: "Stable",
panels : [
]
}
panels : []
};
_.defaults($scope.panel,_d);
$scope.init = function(){
$scope.reset_panel();
}
};
$scope.toggle_row = function(panel) {
panel.collapse = panel.collapse ? false : true;
......@@ -36,15 +35,15 @@ angular.module('kibana.column', [])
$scope.send_render();
});
}
}
};
$scope.send_render = function() {
$scope.$broadcast('render');
}
};
$scope.add_panel = function(panel) {
$scope.panel.panels.push(panel);
}
};
$scope.reset_panel = function(type) {
$scope.new_panel = {
......@@ -76,17 +75,17 @@ angular.module('kibana.column', [])
$timeout(function() {
// Create a reference to the new_panel as panel so that the existing
// editors work with our isolate scope
scope.panel = scope.new_panel
var template = '<div ng-include src="\'panels/column/panelgeneral.html\'"></div>'
scope.panel = scope.new_panel;
var template = '<div ng-include src="\'panels/column/panelgeneral.html\'"></div>';
if(!(_.isUndefined(scope.type)) && scope.type != "")
if(!(_.isUndefined(scope.type)) && scope.type !== "") {
template = template+'<div ng-include src="\'panels/'+scope.type+'/editor.html\'"></div>';
//var new_elem = $compile(angular.element(template))(scope))
}
elem.html($compile(angular.element(template))(scope));
})
})
});
});
}
}
};
}).filter('withoutColumn', function() {
return function() {
return _.without(config.modules,'column');
......
/*jshint globalstrict:true */
/*global angular:true */
/*global FileReader:false*/
/*
## Dashcontrol
......@@ -20,11 +23,8 @@
* temp :: Allow saving of temp dashboards
* temp_ttl :: How long should temp dashboards persist
### Group Events
#### Sends
* dashboard :: An object containing an entire dashboard to be loaded
*/
'use strict';
angular.module('kibana.dashcontrol', [])
.controller('dashcontrol', function($scope, $http, timer, dashboard) {
......@@ -49,7 +49,7 @@ angular.module('kibana.dashcontrol', [])
elasticsearch_size: 20,
temp: true,
temp_ttl: '30d'
}
};
_.defaults($scope.panel,_d);
// A hash of defaults for the dashboard object
......@@ -58,63 +58,63 @@ angular.module('kibana.dashcontrol', [])
editable: true,
rows: [],
services: {}
}
};
$scope.init = function() {
$scope.gist_pattern = /(^\d{5,}$)|(^[a-z0-9]{10,}$)|(gist.github.com(\/*.*)\/[a-z0-9]{5,}\/*$)/;
$scope.gist = {};
$scope.elasticsearch = {};
}
};
$scope.set_default = function() {
if(dashboard.set_default()) {
$scope.alert('Local Default Set',dashboard.current.title+' has been set as your local default','success',5000)
$scope.alert('Local Default Set',dashboard.current.title+' has been set as your local default','success',5000);
} else {
$scope.alert('Incompatible Browser','Sorry, your browser is too old for this feature','error',5000)
$scope.alert('Incompatible Browser','Sorry, your browser is too old for this feature','error',5000);
}
}
};
$scope.purge_default = function() {
if(dashboard.purge_default()) {
$scope.alert('Local Default Clear','Your local default dashboard has been cleared','success',5000)
$scope.alert('Local Default Clear','Your local default dashboard has been cleared','success',5000);
} else {
$scope.alert('Incompatible Browser','Sorry, your browser is too old for this feature','error',5000)
$scope.alert('Incompatible Browser','Sorry, your browser is too old for this feature','error',5000);
}
}
};
$scope.elasticsearch_save = function(type,ttl) {
dashboard.elasticsearch_save(type,($scope.elasticsearch.title || dashboard.current.title),ttl).then(
function(result) {
if(!_.isUndefined(result._id)) {
$scope.alert('Dashboard Saved','This dashboard has been saved to Elasticsearch as "' +
result._id + '"','success',5000)
result._id + '"','success',5000);
if(type === 'temp') {
$scope.share = dashboard.share_link(dashboard.current.title,'temp',result._id)
$scope.share = dashboard.share_link(dashboard.current.title,'temp',result._id);
}
} else {
$scope.alert('Save failed','Dashboard could not be saved to Elasticsearch','error',5000)
$scope.alert('Save failed','Dashboard could not be saved to Elasticsearch','error',5000);
}
})
}
});
};
$scope.elasticsearch_delete = function(id) {
dashboard.elasticsearch_delete(id).then(
function(result) {
if(!_.isUndefined(result)) {
if(result.found) {
$scope.alert('Dashboard Deleted',id+' has been deleted','success',5000)
$scope.alert('Dashboard Deleted',id+' has been deleted','success',5000);
// Find the deleted dashboard in the cached list and remove it
var toDelete = _.where($scope.elasticsearch.dashboards,{_id:id})[0]
$scope.elasticsearch.dashboards = _.without($scope.elasticsearch.dashboards,toDelete)
var toDelete = _.where($scope.elasticsearch.dashboards,{_id:id})[0];
$scope.elasticsearch.dashboards = _.without($scope.elasticsearch.dashboards,toDelete);
} else {
$scope.alert('Dashboard Not Found','Could not find '+id+' in Elasticsearch','warning',5000)
$scope.alert('Dashboard Not Found','Could not find '+id+' in Elasticsearch','warning',5000);
}
} else {
$scope.alert('Dashboard Not Deleted','An error occurred deleting the dashboard',error,5000)
$scope.alert('Dashboard Not Deleted','An error occurred deleting the dashboard','error',5000);
}
}
)
}
);
};
$scope.elasticsearch_dblist = function(query) {
dashboard.elasticsearch_list(query,$scope.panel.elasticsearch_size).then(
......@@ -122,10 +122,10 @@ angular.module('kibana.dashcontrol', [])
if(!_.isUndefined(result.hits)) {
$scope.panel.error = false;
$scope.hits = result.hits.total;
$scope.elasticsearch.dashboards = result.hits.hits
$scope.elasticsearch.dashboards = result.hits.hits;
}
})
}
});
};
$scope.save_gist = function() {
dashboard.save_gist($scope.gist.title).then(
......@@ -134,10 +134,10 @@ angular.module('kibana.dashcontrol', [])
$scope.gist.last = link;
$scope.alert('Gist saved','You will be able to access your exported dashboard file at <a href="'+link+'">'+link+'</a> in a moment','success');
} else {
$scope.alert('Save failed','Gist could not be saved','error',5000)
$scope.alert('Save failed','Gist could not be saved','error',5000);
}
})
}
});
};
$scope.gist_dblist = function(id) {
dashboard.gist_list(id).then(
......@@ -145,10 +145,10 @@ angular.module('kibana.dashcontrol', [])
if(files && files.length > 0) {
$scope.gist.files = files;
} else {
$scope.alert('Gist Failed','Could not retrieve dashboard list from gist','error',5000)
$scope.alert('Gist Failed','Could not retrieve dashboard list from gist','error',5000);
}
})
}
});
};
})
.directive('dashUpload', function(timer, dashboard){
return {
......@@ -159,14 +159,15 @@ angular.module('kibana.dashcontrol', [])
// files is a FileList of File objects. List some properties.
var output = [];
var readerOnload = function(theFile) {
return function(e) {
dashboard.dash_load(JSON.parse(e.target.result));
scope.$apply();
};
};
for (var i = 0, f; f = files[i]; i++) {
var reader = new FileReader();
reader.onload = (function(theFile) {
return function(e) {
dashboard.dash_load(JSON.parse(e.target.result))
scope.$apply();
};
})(f);
reader.onload = (readerOnload)(f);
reader.readAsText(f);
}
}
......@@ -179,15 +180,16 @@ angular.module('kibana.dashcontrol', [])
alert('Sorry, the HTML5 File APIs are not fully supported in this browser.');
}
}
}
};
}).filter('gistid', function() {
var gist_pattern = /(\d{5,})|([a-z0-9]{10,})|(gist.github.com(\/*.*)\/[a-z0-9]{5,}\/*$)/;
return function(input, scope) {
//return input+"boners"
if(!(_.isUndefined(input))) {
var output = input.match(gist_pattern);
if(!_.isNull(output) && !_.isUndefined(output))
return output[0].replace(/.*\//, '');
}
var gist_pattern = /(\d{5,})|([a-z0-9]{10,})|(gist.github.com(\/*.*)\/[a-z0-9]{5,}\/*$)/;
return function(input, scope) {
//return input+"boners"
if(!(_.isUndefined(input))) {
var output = input.match(gist_pattern);
if(!_.isNull(output) && !_.isUndefined(output)) {
return output[0].replace(/.*\//, '');
}
}
});;
\ No newline at end of file
};
});
\ No newline at end of file
......@@ -80,8 +80,12 @@ angular.module('kibana.fields', [])
}
$scope.build_search = function(field,value,mandate) {
var query = field+":"+angular.toJson(value)
var query;
if(_.isArray(value)) {
query = field+":(" + _.map(value,function(v){return "\""+v+"\""}).join(",") + ")";
} else {
query = field+":"+angular.toJson(value);
}
filterSrv.set({type:'querystring',query:query,mandate:mandate})
dashboard.refresh();
}
......
......@@ -18,7 +18,7 @@
.filter-mustNot {
border-bottom: #E24D42 3px solid;
}
.filter-should {
.filter-either {
border-bottom: #EF843C 3px solid;
}
.filter-action {
......@@ -26,13 +26,24 @@
margin-bottom: 0px !important;
margin-left: 3px;
}
.filter-mandate {
text-decoration: underline;
cursor: pointer;
}
</style>
<div class='filtering-container'>
<div ng-repeat="id in filterSrv.ids" class="small filter-panel-filter">
<div class="filter-{{filterSrv.list[id].mandate}}">
<strong>{{filterSrv.list[id].type}}</strong> {{filterSrv.list[id].mandate}}
<strong>{{filterSrv.list[id].type}}</strong>
<span ng-show="!filterSrv.list[id].editing" class="filter-mandate" ng-click="filterSrv.list[id].editing = true">{{filterSrv.list[id].mandate}}</span>
<span class="small" ng-show="filterSrv.list[id].editing">
<select class="input-small" ng-model="filterSrv.list[id].mandate" ng-options="f for f in ['must','mustNot','either']" ng-change='filterSrv.list[id].editing=undefined;refresh()'></select>
<i class="pointer icon-remove" bs-tooltip="'Cancel '" ng-click="filterSrv.list[id].editing=undefined"></i>
</span>
<i class="filter-action pointer icon-remove" bs-tooltip="'Remove'" ng-click="remove(id)"></i>
<i class="filter-action pointer" ng-class="{'icon-check': filterSrv.list[id].active,'icon-check-empty': !filterSrv.list[id].active}" bs-tooltip="'Toggle'" ng-click="toggle(id)"></i>
......
......@@ -40,7 +40,7 @@ angular.module('kibana.filtering', [])
}
$scope.show_key = function(key) {
return !_.contains(['type','id','alias','mandate','active'],key)
return !_.contains(['type','id','alias','mandate','active','editing'],key)
}
});
\ No newline at end of file
......@@ -107,7 +107,13 @@ angular.module('kibana.table', [])
}
$scope.build_search = function(field,value,negate) {
var query = field+":"+angular.toJson(value)
var query;
// This needs to be abstracted somewhere
if(_.isArray(value)) {
query = field+":(" + _.map(value,function(v){return "\""+v+"\""}).join(",") + ")";
} else {
query = field+":"+angular.toJson(value);
}
filterSrv.set({type:'querystring',query:query,mandate:(negate ? 'mustNot':'must')})
$scope.panel.offset = 0;
dashboard.refresh();
......@@ -216,7 +222,7 @@ angular.module('kibana.table', [])
$scope.modal = {
title: "Table Inspector",
body : "<h5>Last Elasticsearch Query</h5><pre>"+
'curl -XGET '+config.elasticsearch+'/'+$scope.index+"/_search?pretty -d'\n"+
'curl -XGET '+config.elasticsearch+'/'+dashboard.indices+"/_search?pretty -d'\n"+
angular.toJson(JSON.parse(request.toString()),true)+
"'</pre>",
}
......
/*jshint globalstrict:true */
/*global angular:true */
/*
## Hits
## Trends
A variety of representations of the hits a query matches
Shows how queries are moving from a specified time ago
### Parameters
* query :: An array of queries. No labels here, just an array of strings. Maybe
there should be labels. Probably.
* style :: A hash of css styles
* arrangement :: How should I arrange the query results? 'horizontal' or 'vertical'
* ago :: Date math formatted time to look back
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment