Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(1951)

Unified Diff: appengine/swarming/elements/res/imp/botlist/bot-filters.html

Issue 2182693002: Add new botlist for swarming (Closed) Base URL: https://chromium.googlesource.com/external/github.com/luci/luci-py@app-wrapper
Patch Set: Adjust font and layout a bit more Created 4 years, 5 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
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>

Powered by Google App Engine
This is Rietveld 408576698