| Index: appengine/swarming/elements/res/imp/botlist/bot-filters.html
|
| diff --git a/appengine/swarming/elements/res/imp/botlist/bot-filters.html b/appengine/swarming/elements/res/imp/botlist/bot-filters.html
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..b1f2648df7c9802a1a41712faa4ee1183a7117f6
|
| --- /dev/null
|
| +++ b/appengine/swarming/elements/res/imp/botlist/bot-filters.html
|
| @@ -0,0 +1,554 @@
|
| +<!--
|
| + Copyright 2016 The LUCI Authors. All rights reserved.
|
| + Use of this source code is governed under the Apache License, Version 2.0
|
| + that can be found in the LICENSE file.
|
| +
|
| + This in an HTML Import-able file that contains the definition
|
| + of the following elements:
|
| +
|
| + <bot-filters>
|
| +
|
| + This element allows the user to pick which columns they want to see on the bot
|
| + list and which filters should be applied.
|
| +
|
| + Usage:
|
| +
|
| + <bot-filters></bot-filters>
|
| +
|
| + Properties:
|
| + // inputs
|
| + primary_map: Object, a mapping of primary keys to secondary items.
|
| + The primary keys are things that can be columns or sorted by. The
|
| + primary values (aka the secondary items) are things that can be filtered
|
| + on. Primary consists of dimensions and state. Secondary contains the
|
| + values primary things can be.
|
| + primary_arr: Array<String>, the display order of the primary keys.
|
| +
|
| + // outputs
|
| + columns: Array<String>, the columns that should be displayed.
|
| + filter: Object, an object {filter:Function} where filter will take one param
|
| + (bot) and return a Boolean if it should be displayed given the
|
| + current filters.
|
| + verbose: Boolean, if the data displayed should be verbose.
|
| +
|
| + Methods:
|
| + None.
|
| +
|
| + Events:
|
| + None.
|
| +-->
|
| +
|
| +<link rel="import" href="/res/imp/bower_components/iron-flex-layout/iron-flex-layout-classes.html">
|
| +<link rel="import" href="/res/imp/bower_components/iron-icons/iron-icons.html">
|
| +<link rel="import" href="/res/imp/bower_components/iron-selector/iron-selector.html">
|
| +<link rel="import" href="/res/imp/bower_components/paper-checkbox/paper-checkbox.html">
|
| +<link rel="import" href="/res/imp/bower_components/paper-icon-button/paper-icon-button.html">
|
| +<link rel="import" href="/res/imp/bower_components/paper-input/paper-input.html">
|
| +
|
| +
|
| +<dom-module id="bot-filters">
|
| + <template>
|
| + <style is="custom-style" include="iron-flex iron-flex-alignment iron-positioning">
|
| + :host {
|
| + display: block;
|
| + font-family: sans-serif;
|
| + }
|
| + #filter {
|
| + margin:0 5px;
|
| + }
|
| +
|
| + .container {
|
| + min-height: 120px;
|
| + width: 100%;
|
| + }
|
| +
|
| + .item {
|
| + border-bottom: 1px solid #EEE;
|
| + max-width: 250px;
|
| + min-height: 1.0em;
|
| + min-width: 100px;
|
| + padding: 0.1em 0.2em;
|
| + line-height: 1.5em;
|
| + }
|
| +
|
| + .header {
|
| + height: 2em;
|
| + padding: .25em;
|
| + line-height: 2em;
|
| + }
|
| +
|
| + .selector {
|
| + border: 1px solid black;
|
| + margin: 0 5px;
|
| + max-height: 200px;
|
| + min-height: 130px;
|
| + min-width: 200px;
|
| + overflow-y: auto;
|
| + overflow-x: hidden;
|
| + }
|
| +
|
| + .selectable {
|
| + cursor: pointer;
|
| + }
|
| +
|
| + .selectable:hover {
|
| + /* See https://sites.google.com/a/google.com/skia-infrastructure/design-docs/general-design-guidance */
|
| + background-color: #A6CEE3;
|
| + }
|
| +
|
| + .iron-selected {
|
| + /* See https://sites.google.com/a/google.com/skia-infrastructure/design-docs/general-design-guidance */
|
| + background-color: #1F78B4;
|
| + color: white;
|
| + }
|
| +
|
| + .icons {
|
| + cursor:pointer;
|
| + height:20px;
|
| + margin:2px;
|
| + width:20px;
|
| + flex-shrink: 0;
|
| + }
|
| +
|
| + .side-by-side {
|
| + display: inline-block;
|
| + vertical-align: top;
|
| + }
|
| +
|
| + .bold {
|
| + font-weight: bold;
|
| + }
|
| +
|
| + paper-checkbox {
|
| + max-height: 2em;
|
| + margin: 2px;
|
| + /* See https://sites.google.com/a/google.com/skia-infrastructure/design-docs/general-design-guidance */
|
| + --paper-checkbox-checked-color: black;
|
| + --paper-checkbox-checked-ink-color: black;
|
| + --paper-checkbox-unchecked-color: black;
|
| + --paper-checkbox-unchecked-ink-color: black;
|
| + --paper-checkbox-label-color: black;
|
| + }
|
| + </style>
|
| +
|
| + <div class="container horizontal layout">
|
| + <!--
|
| + A common pattern below is to do something like
|
| + checked="[[_columnState(col,columns.*)]]"
|
| + The last argument here allows this value to change if anything in the
|
| + columns array is added or removed. Arrays are weird in Polymer and this is
|
| + the best way to listen to those changes.
|
| + -->
|
| +
|
| + <div class="narrow-down-selector">
|
| + <div>
|
| + <paper-input id="filter"
|
| + label="Search columns and filters"
|
| + placeholder="gpu nvidia"
|
| + value="{{_query}}"></paper-input>
|
| + </div>
|
| +
|
| + <div class="selector side-by-side">
|
| + <iron-selector attr-for-selected="label" selected="{{_primarySelected}}">
|
| + <template is="dom-repeat" items="[[_primaryItems]]" as="item">
|
| + <div class="selectable item horizontal layout" label="[[item]]">
|
| + <!-- No line break here to avoid awkward spaces-->
|
| + <span>[[_beforeBold(item,_query)]]<span class="bold">[[_bold(item,_query)]]</span>[[_afterBold(item,_query)]]</span>
|
| + <span class="flex"></span>
|
| + <paper-checkbox
|
| + noink
|
| + disabled$="[[_cantToggleColumn(item)]]"
|
| + checked="[[_columnState(item,columns.*)]]"
|
| + on-change="_toggleColumn">
|
| + </paper-checkbox>
|
| + </div>
|
| + </template>
|
| + </iron-selector>
|
| + </div>
|
| +
|
| + <div class="selector side-by-side">
|
| + <template is="dom-repeat" id="secondaryList"
|
| + items="[[_secondaryItems]]" as="item">
|
| + <div class="item horizontal layout" label="[[item]]">
|
| + <!-- No line break here to avoid awkward spaces-->
|
| + <span>[[_beforeBold(item,_query)]]<span class="bold">[[_bold(item,_query)]]</span>[[_afterBold(item,_query)]]</span>
|
| + <span class="flex"></span>
|
| + <iron-icon
|
| + class="icons"
|
| + icon="icons:arrow-forward"
|
| + hidden="[[_cantAddFilter(_primarySelected,item,_filters.*)]]"
|
| + on-tap="_addFilter">
|
| + </iron-icon>
|
| + </div>
|
| + </template>
|
| + </div>
|
| +
|
| + <div class="selector side-by-side">
|
| + <template is="dom-repeat" items="[[_filters]]" as="fil">
|
| + <div class="item horizontal layout" label="[[fil]]">
|
| + <span>[[fil]]</span>
|
| + <span class="flex"></span>
|
| + <iron-icon
|
| + class="icons"
|
| + icon="icons:remove-circle-outline"
|
| + hidden="[[_cantRemoveFilter(fil,_filters.*)]]"
|
| + on-tap="_removeFilter">
|
| + </iron-icon>
|
| + </div>
|
| + </template>
|
| + </div>
|
| +
|
| + <paper-checkbox checked="{{verbose}}">Verbose Entries</paper-checkbox>
|
| + </div>
|
| +
|
| + </div>
|
| +
|
| + </template>
|
| + <script>
|
| + (function(){
|
| + var FILTER_SEP = " | ";
|
| + // filterMap is a map of primary -> function. The function returns a
|
| + // boolean "does the bot (first arg) match the second argument". These
|
| + // functions will have "this" be the botlist, and will have access to all
|
| + // functions defined in bot-list and bot-list-shared.
|
| + var filterMap = {
|
| + cores: function(bot, cores){
|
| + var o = this._cores(bot);
|
| + return o.indexOf(cores) !== -1;
|
| + },
|
| + cpu: function(bot, cpu){
|
| + var o = this._dimension(bot, "cpu") || ["none"];
|
| + return o.indexOf(cpu) !== -1;
|
| + },
|
| + devices: function(bot, device){
|
| + if (device === "none") {
|
| + return this._devices(bot).length === 0;
|
| + }
|
| + // extract the deviceType, if it is not "unknown".
|
| + device = this._unalias(device);
|
| + var found = false;
|
| + this._devices(bot).forEach(function(d) {
|
| + if (this._deviceType(d) === device) {
|
| + found = true;
|
| + }
|
| + }.bind(this));
|
| + return found;
|
| + },
|
| + gpu: function(bot, gpu){
|
| + var o = this._dimension(bot, "gpu") || ["none"];
|
| + return o.indexOf(this._unalias(gpu)) !== -1;
|
| + },
|
| + id: function(bot, id) {
|
| + return bot.bot_id === id;
|
| + },
|
| + os: function(bot, os){
|
| + var o = this._dimension(bot, "os") || ["Unknown"];
|
| + return o.indexOf(os) !== -1;
|
| + },
|
| + pool: function(bot, pool){
|
| + var o = this._dimension(bot, "pool") || ["Unknown"];
|
| + return o.indexOf(pool) !== -1;
|
| + },
|
| + status: function(bot, status){
|
| + if (status === "quarantined") {
|
| + return bot.quarantined;
|
| + } else if (status === "dead") {
|
| + return bot.is_dead;
|
| + } else {
|
| + // Status must be "available".
|
| + return !bot.quarantined && !bot.is_dead;
|
| + }
|
| + },
|
| + task: function(bot, task) {
|
| + if (task === "idle") {
|
| + return this._taskId(bot) === "idle";
|
| + }
|
| + // Task must be "busy".
|
| + return this._taskId(bot) !== "idle";
|
| + }
|
| + };
|
| +
|
| + // Given a space separated list of queries, matchPartCaseInsensitive
|
| + // returns an object of any query that matches a part of str, case
|
| + // insensitive. The object has an idx (index) and the part that matched.
|
| + var matchPartCaseInsensitive = function(str, queries) {
|
| + if (!queries) {
|
| + return {
|
| + idx: 0,
|
| + part: "",
|
| + };
|
| + }
|
| + if (!str) {
|
| + return {
|
| + idx: -1,
|
| + };
|
| + }
|
| + queries = queries.trim().toLocaleLowerCase();
|
| + str = str.toLocaleLowerCase();
|
| + var xq = queries.split(" ");
|
| + for (var i = 0; i < xq.length; i++) {
|
| + var idx = str.indexOf(xq[i]);
|
| + if (idx !== -1) {
|
| + return {
|
| + idx: idx,
|
| + part: xq[i],
|
| + };
|
| + }
|
| + }
|
| + return {
|
| + idx: -1,
|
| + };
|
| + };
|
| +
|
| + Polymer({
|
| + is: "bot-filters",
|
| + properties: {
|
| + // input
|
| + primary_map: {
|
| + type: Object,
|
| + },
|
| + primary_arr: {
|
| + type: Array,
|
| + },
|
| +
|
| + // output
|
| + columns: {
|
| + type: Array,
|
| + value: function() {
|
| + // TODO(kjlubick) back these up to URL params and load them from
|
| + // there on reload.
|
| + return ["id","os","task","status"];
|
| + },
|
| + notify: true,
|
| + },
|
| + filter: {
|
| + type: Object,
|
| + computed: "_makeFilter(_filters.*)",
|
| + notify: true,
|
| + },
|
| + verbose: {
|
| + type: Boolean,
|
| + value: false,
|
| + notify: true,
|
| + },
|
| +
|
| + // private
|
| + _filters: {
|
| + type:Array,
|
| + value: function() {
|
| + return [];
|
| + }
|
| + },
|
| + _primaryItems: {
|
| + type: Array,
|
| + computed: "_primary(_query, primary_map, primary_arr, columns.*)",
|
| + },
|
| + _primarySelected: {
|
| + type: String,
|
| + value: "",
|
| + },
|
| + // query is treated as a space separated list.
|
| + _query: {
|
| + type:String,
|
| + value: "",
|
| + },
|
| + _secondaryItems: {
|
| + type: Array,
|
| + computed: "_secondary(_primarySelected, _query, primary_map)",
|
| + },
|
| +
|
| + },
|
| +
|
| + _addFilter: function(e) {
|
| + // e.model.foo is a way to get access to the "foo" inside a dom-repeat
|
| + // that had the event (in our case, a tap) acted upon it. This name,
|
| + // "foo", is set by the dom-repeat above 'as="foo"'
|
| + var filterItem = e.model.item;
|
| + if (this._cantAddFilter(this._primarySelected, filterItem)) {
|
| + return;
|
| + }
|
| + var filter = this._primarySelected + FILTER_SEP + filterItem;
|
| + this.push("_filters", filter);
|
| + },
|
| +
|
| + _removeFilter: function(e){
|
| + var filter = e.model.fil;
|
| + if (this._cantRemoveFilter(filter)){
|
| + return;
|
| + }
|
| + var idx = this._filters.indexOf(filter);
|
| + if (idx !== -1) {
|
| + this.splice("_filters", idx, 1);
|
| + }
|
| + },
|
| +
|
| + _cantAddFilter: function(primarySelected, filterItem) {
|
| + // Check that everything is selected and this filter isn't already in
|
| + // the array.
|
| + if (!primarySelected || !filterItem) {
|
| + return true;
|
| + }
|
| + var filter = primarySelected + FILTER_SEP + filterItem;
|
| + return this._filters.indexOf(filter) !== -1;
|
| + },
|
| +
|
| + _cantRemoveFilter: function(filter) {
|
| + return !filter || this._filters.indexOf(filter) === -1;
|
| + },
|
| +
|
| + _toggleColumn: function(e) {
|
| + var col = e.model.item;
|
| +
|
| + if (this._cantToggleColumn(col)) {
|
| + return;
|
| + }
|
| + if (this._columnState(col)) {
|
| + var idx = this.columns.indexOf(col);
|
| + if (idx !== -1) {
|
| + this.splice("columns", idx, 1);
|
| + }
|
| + return;
|
| + }
|
| + this.push("columns", col);
|
| + },
|
| +
|
| + _cantToggleColumn: function(col) {
|
| + // Don't allow the id column to be removed, as the bot list is basically
|
| + // meaningless without it.
|
| + return !col || col === "id" ;
|
| + },
|
| +
|
| + _columnState: function(col) {
|
| + if (!col) {
|
| + return false;
|
| + }
|
| + return this.columns.indexOf(col) !== -1;
|
| + },
|
| +
|
| + _makeFilter: function() {
|
| + // The filters belonging to the same primary key will be or'd together.
|
| + // Those groups will be and'd together.
|
| + // filterGroups will be a map of primary (i.e. column) -> array of
|
| + // options that should be filtered to.
|
| + // e.g. "os" -> ["Windows", "Linux"]
|
| + // Since they will be or'd together, order doesn't matter.
|
| + var filterGroups = {};
|
| + this._filters.forEach(function(filterString){
|
| + var idx = filterString.indexOf(FILTER_SEP);
|
| + var primary = filterString.slice(0, idx);
|
| + var param = filterString.slice(idx + FILTER_SEP.length);
|
| + var arr = filterGroups[primary] || [];
|
| + arr.push(param);
|
| + filterGroups[primary] = arr;
|
| + });
|
| + return {
|
| + filter: function(bot){
|
| + var retVal = true;
|
| + // Look up all the primary keys we are filter by, then look up how
|
| + // to filter (in filterMap) and apply the filter for each filter
|
| + // option.
|
| + for (primary in filterGroups){
|
| + var params = filterGroups[primary];
|
| + var filter = filterMap[primary];
|
| + var groupResult = false;
|
| + if (filter) {
|
| + params.forEach(function(param){
|
| + groupResult = groupResult || filter.bind(this)(bot,param);
|
| + }.bind(this));
|
| + }
|
| + retVal = retVal && groupResult;
|
| + }
|
| + return retVal;
|
| + }
|
| + }
|
| + },
|
| +
|
| + _primary: function(query, primary_map, primary_arr) {
|
| + // If the user has typed in a query, only show those primary keys that
|
| + // partially match the query or that have secondary values which
|
| + // partially match.
|
| + var arr = this.primary_arr.filter(function(s){
|
| + if (matchPartCaseInsensitive(s, query).idx !== -1) {
|
| + return true;
|
| + }
|
| + var opts = primary_map[s];
|
| + for (var i = 0; i < opts.length; i++) {
|
| + if (matchPartCaseInsensitive(opts[i], query).idx !== -1) {
|
| + return true;
|
| + }
|
| + }
|
| + return false;
|
| + });
|
| + // Update the selected to be the current one (if it is still with being
|
| + // shown) or the first match. This saves the user from having to click
|
| + // the first result before seeing results.
|
| + if (query && arr.length > 0 &&
|
| + arr.indexOf(this._primarySelected) === -1) {
|
| + this.set("_primarySelected", arr[0]);
|
| + }
|
| + return arr;
|
| + },
|
| +
|
| + _secondary: function(primarySelected, query, primary_map) {
|
| + // Changing the secondary list doesn't always trigger a reorder of the
|
| + // secondary elements. So, we request it be done asynchronously.
|
| + requestAnimationFrame(function(){
|
| + this.$.secondaryList.render();
|
| + }.bind(this));
|
| +
|
| + // Only show secondary options when a primary option has been selected.
|
| + // If the user has typed in a query, show all secondary elements if
|
| + // their primary element matches. If it doesn't match the primary
|
| + // element, only show those secondary elements that do.
|
| + if (!primarySelected) {
|
| + return [];
|
| + }
|
| + if (matchPartCaseInsensitive(primarySelected, query).idx !== -1) {
|
| + // Sort the secondaries alphabetically, but prioritize query matches.
|
| + return primary_map[primarySelected].sort(function(a, b){
|
| + var aMatch = matchPartCaseInsensitive(a, query).idx !== -1;
|
| + var bMatch = matchPartCaseInsensitive(b, query).idx !== -1;
|
| + if (aMatch === bMatch) {
|
| + return swarming.naturalCompare(a,b);
|
| + }
|
| + // true == 1 and false == 0. So, put the one that matches first.
|
| + return bMatch - aMatch;
|
| + });
|
| + }
|
| + // Otherwise, filter out those that do not match.
|
| + return primary_map[primarySelected].filter(function(s) {
|
| + return matchPartCaseInsensitive(s, query).idx !== -1;
|
| + });
|
| + },
|
| +
|
| + // These three methods (_beforeBold, _bold, _afterBold) bold the first
|
| + // instance of the filter query, making it easier to see why elements
|
| + // show up.
|
| + _beforeBold: function(item, query) {
|
| + var match = matchPartCaseInsensitive(item, query);
|
| + if (match.idx === -1) {
|
| + return item;
|
| + }
|
| + return item.substring(0, match.idx);
|
| + },
|
| +
|
| + _bold: function(item, query) {
|
| + var match = matchPartCaseInsensitive(item, query);
|
| + if (match.idx === -1) {
|
| + return "";
|
| + }
|
| + return item.substring(match.idx, match.idx + match.part.length);
|
| + },
|
| +
|
| + _afterBold: function(item, query) {
|
| + var match = matchPartCaseInsensitive(item, query);
|
| + if (match.idx === -1) {
|
| + return "";
|
| + }
|
| + return item.substring(match.idx + match.part.length);
|
| + },
|
| +
|
| + });
|
| + })();
|
| + </script>
|
| +</dom-module>
|
|
|