Implement scripting for KPI reports

KPI reports can be generated from script in yaml format. Scripting
provides a way to specify goals in human-readable form. Targets can be
grouped by modules, companies and users. Single target can be of following
types: to reach metric value, to reach position in top, to reach
minimum percentage value or to become core engineer. The example
is located in stackalytics/dashboard/templates/kpi/kpi_script.html

Change-Id: Idec4db58f5134d84009ba982121af3f086e4ad46
This commit is contained in:
Ilya Shakhat
2014-07-17 14:06:54 +04:00
parent 86a2e64b7f
commit 42ab18eacc
7 changed files with 468 additions and 153 deletions

View File

@@ -487,3 +487,9 @@ div.stackamenu li.current-menu-item a span {
font-size: 16px;
color: #972D24;
}
.error {
font-weight: bold;
color: red;
margin: 0.5em 0;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,401 @@
/*
Copyright (c) 2014 Mirantis Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
function showError(container, message) {
container.append($("<pre class='error'>Error! " + message + "</pre>"));
}
function appendKpiBlock(container_id, kpi_block) {
var container = container_id;
if (typeof container_id == "string") {
container = $("#" + container_id);
}
var template = $("#kpi_block_template");
if (template.length > 0) {
container.append(template.tmpl(kpi_block));
} else {
container.append($("<pre>" + JSON.stringify(kpi_block) + "</pre>"));
}
}
function processStats(container_id, url, query_options, item_id, metric, text_goal, comparator, data_filter) {
$.ajax({
url: makeURI(url, query_options),
dataType: "jsonp",
success: function (data) {
data = data["stats"];
var position = -1;
var index = 0;
var sum = 0;
for (var i = 0; i < data.length; i++) {
if (data_filter) {
if (!data_filter(data[i])) {
continue;
}
}
sum += data[i][metric];
data[i].index = ++index; // re-index
if (data[i].id == item_id) {
position = i;
}
}
var result = {
mark: false,
text_goal: text_goal
};
if (position < 0) {
result.info = "Item " + item_id + " is not found in the stats";
}
else {
var comparison_result = comparator(data[position], sum);
result.mark = comparison_result.mark;
result.info = comparison_result.info;
}
appendKpiBlock(container_id, result);
}
});
}
function goalPositionInTop(container_id, query_options, item_type, item_id, position, text_goal, data_filter) {
$(document).ready(function () {
processStats(container_id, "/api/1.0/stats/" + item_type, query_options, item_id, "metric", text_goal,
function (item, sum) {
var mark = item.index <= position;
return {
mark: mark,
info: mark ? "Achieved position is " + item.index :
"Position " + item.index + " is worse than the goal position " + position,
value: item.index
}
}, data_filter);
});
}
function goalMetric(container_id, query_options, item_type, item_id, target, text_goal) {
$(document).ready(function () {
processStats(container_id, "/api/1.0/stats/" + item_type, query_options, item_id, "metric", text_goal,
function (item, sum) {
var mark = item.metric >= target;
return {
mark: mark,
info: mark ? "Achieved metric " + item.metric :
"Metric " + item.metric + " is worse than the goal in " + target,
value: item.index
}
});
});
}
function goalPercentageInTopLessThan(container_id, query_options, item_type, item_id, target_percentage, text_goal) {
$(document).ready(function () {
processStats(container_id, "/api/1.0/stats/" + item_type, query_options, item_id, "metric", text_goal,
function (item, sum) {
var percentage = item.metric / sum;
var mark = percentage <= target_percentage;
var percentage_formatted = Math.round(percentage * 100) + "%";
var goal_percentage_formatted = Math.round(target_percentage * 100) + "%";
return {
mark: mark,
info: mark ? "Achieved percentage " + percentage_formatted :
"Value " + percentage_formatted + " is more than the goal " + goal_percentage_formatted,
value: percentage_formatted
}
});
});
}
function goalDisagreementRatioLessThan(container_id, query_options, item_id, target_percentage, text_goal) {
$(document).ready(function () {
processStats(container_id, "/api/1.0/stats/engineers", query_options, item_id, "disagreement_ratio", text_goal,
function (item, sum) {
var percentage = parseFloat(item["disagreement_ratio"]);
var mark = percentage < target_percentage * 100;
var goal_percentage_formatted = Math.round(target_percentage * 100) + "%";
return {
mark: mark,
info: mark ? "Achieved percentage " + item["disagreement_ratio"] :
"Value " + item["disagreement_ratio"] + " is more than the goal " + goal_percentage_formatted,
value: percentage
}
});
});
}
function goalCoreEngineerInProject(container_id, user_id, project, text_goal) {
$(document).ready(function () {
$.ajax({
url: makeURI("/api/1.0/users/" + user_id),
dataType: "jsonp",
success: function (data) {
var user = data.user;
var is_core = false;
if (user.core) {
for (var i in user.core) {
if (user.core[i][0] == project) {
is_core = true;
}
}
}
var result = {
mark: is_core,
text_goal: text_goal,
info: user.user_name + " (" + user_id + ") is " + (is_core ? "" : "not ") + "core engineer in " + project
};
appendKpiBlock(container_id, result);
},
error: function () {
var result = {
mark: false,
text_goal: text_goal,
info: "Item " + user_id + " is not found in the stats"
};
appendKpiBlock(container_id, result);
}
});
});
}
function loadAndShowUserProfile(container, user_id) {
$.ajax({
url: makeURI("/api/1.0/users/" + user_id),
dataType: "json",
success: function (data) {
var user = data["user"];
container.html(user["user_name"] + " (" + user["user_id"] + ")");
}
});
}
function loadAndShowModuleDetails(container, module_id) {
$.ajax({
url: makeURI("/api/1.0/modules/" + module_id),
dataType: "json",
success: function (data) {
var module = data["module"];
container.html(module["name"] + " (" + module["id"] + ")");
}
});
}
var now = Math.floor(Date.now() / 1000);
var release_pattern = /Release (\S+)/;
var group_pattern = /Group (\S+)/;
var company_pattern = /Company (\S+)/;
var user_pattern = /User (\S+)/;
var in_pattern = /.*?(\s+in\s+(\S+)).*/;
var during_pattern = /.*?(\s+during\s+(\d+)\s+days).*/;
var make_pattern = /(make|draft|send|write|implement|file|fix|complete)\s+(\d+)\s+(\S+)/;
var top_pattern = /top\s+(\d+)\s+by\s+(\S+)/;
var core_pattern = /(become|stay)\s+core/;
var not_less_than_pattern = /less\s+than\s+(\d+)%\s+by\s+(\S+)/;
function makeKpiRequestOptions(release, metric, module, duration) {
var options = {metric: metric, module: module, project_type: "all"};
if (duration) {
options["start_date"] = now - duration * 60 * 60 * 24;
options["release"] = "all";
} else {
options["release"] = release;
}
return options;
}
function runMakeStatement(statement, verb, count, noun, duration, item_type, item_id, module, release, container) {
var metric = noun;
if (noun == "blueprints") {
metric = (verb == "draft" || verb == "file") ? "bpd" : "bpc";
}
if (noun == "bugs") {
metric = (verb == "file") ? "filed-bugs" : "resolved-bugs";
}
if (noun == "reviews") {
metric = "marks";
}
goalMetric(container, makeKpiRequestOptions(release, metric, module, duration),
item_type, item_id, count, statement);
}
function runTopStatement(statement, position, noun, duration, item_type, item_id, module, release, container) {
var metric = noun;
if (noun == "reviews") {
metric = "marks";
}
goalPositionInTop(container, makeKpiRequestOptions(release, metric, module, duration),
item_type, item_id, position, statement);
}
function runNotLessThanStatement(statement, percentage, noun, duration, item_type, item_id, module, release, container) {
var metric = noun;
if (noun == "reviews") {
metric = "marks";
}
goalPercentageInTopLessThan(container,
makeKpiRequestOptions(release, metric, module, duration),
item_type, item_id, percentage / 100.0, statement);
}
function parseStatements(item_type, item_id, module, release, details, container) {
for (var i in details) {
var original_statement = details[i];
var statement = original_statement;
var local_module = module;
var duration = null;
var pattern_match = in_pattern.exec(statement);
if (pattern_match) {
local_module = pattern_match[2];
statement = statement.replace(pattern_match[1], "");
}
pattern_match = during_pattern.exec(statement);
if (pattern_match) {
duration = pattern_match[2];
statement = statement.replace(pattern_match[1], "");
}
statement = statement.trim();
pattern_match = make_pattern.exec(statement);
if (pattern_match) {
runMakeStatement(original_statement, pattern_match[1], pattern_match[2], pattern_match[3], duration,
item_type, item_id, local_module, release, container);
continue;
}
pattern_match = top_pattern.exec(statement);
if (pattern_match) {
runTopStatement(original_statement, pattern_match[1], pattern_match[2], duration,
item_type, item_id, local_module, release, container);
continue;
}
pattern_match = core_pattern.exec(statement);
if (pattern_match) {
goalCoreEngineerInProject(container, item_id, local_module, original_statement);
continue;
}
pattern_match = not_less_than_pattern.exec(statement);
if (pattern_match) {
runNotLessThanStatement(original_statement, pattern_match[1], pattern_match[2], duration,
item_type, item_id, local_module, release, container);
continue;
}
showError(container, "Could not parse statement: '" + statement + "'");
}
}
function parseGroup(group, release, details, container) {
var users = [];
for (var token in details) {
var pattern_match = user_pattern.exec(token);
if (pattern_match) {
var user = pattern_match[1];
users.push(user);
var body = $("<div id='u" + Math.random() + "'/>");
var user_title_block = $("<h3>" + user + "</h3>");
container.append(user_title_block).append(body);
loadAndShowUserProfile(user_title_block, user);
parseStatements("engineers", user, group, release, details[token], body);
continue;
}
pattern_match = company_pattern.exec(token);
if (pattern_match) {
var company = pattern_match[1];
body = $("<div/>");
container.append($("<h3>" + company + "</h3>")).append(body);
parseStatements("companies", company, group, release, details[token], body);
continue;
}
showError(container, "Could not parse line: '" + details[token] + "'");
}
}
function parseRelease(release, details, container) {
for (var token in details) {
var pattern_match = group_pattern.exec(token);
if (pattern_match) {
var group = pattern_match[1];
var body = $("<div/>");
var title_block = $("<h2>" + group + "</h2>");
container.append(title_block).append(body);
loadAndShowModuleDetails(title_block, group);
parseGroup(group, release, details[token], body);
continue;
}
pattern_match = company_pattern.exec(token);
if (pattern_match) {
var company = pattern_match[1];
body = $("<div/>");
container.append($("<h2>" + company + "</h2>")).append(body);
parseStatements("companies", company, "all", release, details[token], body);
continue;
}
showError(container, "Could not parse line: '" + token + "'");
}
}
function parseKpiScript(parsed_script, container) {
for (var token in parsed_script) {
var pattern_match = release_pattern.exec(token);
if (pattern_match) {
var release = pattern_match[1];
var body = $("<div/>");
$(container).append($("<h1>" + release + "</h1>")).append(body);
parseRelease(release, parsed_script[token], body);
continue;
}
showError(container, "Could not parse line: '" + token + "'");
}
}
function readKpiScript(kpi_script, container_id) {
var root_container = $("#" + container_id).empty();
try {
var parsed_script = jsyaml.safeLoad(kpi_script);
parseKpiScript(parsed_script, root_container);
} catch (e) {
showError(root_container, "Could not parse script: '" + kpi_script + "'");
}
}

View File

@@ -48,13 +48,16 @@
<script type="text/javascript" src="{{ url_for('static', filename='js/jqplot.highlighter.min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/select2.min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/jquery.tmpl.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/jquery.timeago.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/md5.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/jquery.gravatar.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/js-yaml.min.js') }}"></script>
{% if active_tab == 'driverlog' %}
<script type="text/javascript" src="{{ url_for('static', filename='js/driverlog-ui.js') }}"></script>
{% else %}
<script type="text/javascript" src="{{ url_for('static', filename='js/stackalytics-ui.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/stackalytics-kpi.js') }}"></script>
{% endif %}
{% block head %}{% endblock %}

View File

@@ -2,150 +2,6 @@
{% block head %}
<script type="text/javascript">
'use strict';
function process_stats(container_id, url, query_options, item_id, metric, text_goal, comparator, data_filter) {
$.ajax({
url: makeURI(url, query_options),
dataType: "jsonp",
success: function (data) {
data = data["stats"];
var position = -1;
var index = 0;
var sum = 0;
for (var i = 0; i < data.length; i++) {
if (data_filter) {
if (!data_filter(data[i])) {
continue;
}
}
sum += data[i][metric];
data[i].index = ++ index; // re-index
if (data[i].id == item_id) {
position = i;
}
}
var result = {
mark: false,
text_goal: text_goal
};
if (position < 0) {
result.info = "Item " + item_id + " is not found in the stats";
}
else {
var comparison_result = comparator(data[position], sum);
result.mark = comparison_result.mark;
result.info = comparison_result.info;
}
$("#kpi_block_template").tmpl(result).appendTo("#" + container_id);
}
});
}
function goal_position_in_top(container_id, query_options, item_type, item_id, position, text_goal, data_filter) {
$(document).ready(function () {
process_stats(container_id, "/api/1.0/stats/" + item_type, query_options, item_id, "metric", text_goal,
function(item, sum) {
var mark = item.index <= position;
return {
mark: mark,
info: mark? "Achieved position is " + item.index:
"Position " + item.index + " is worse than the goal position " + position,
value: item.index
}
}, data_filter);
});
}
function goal_metric(container_id, query_options, item_type, item_id, target, text_goal) {
$(document).ready(function () {
process_stats(container_id, "/api/1.0/stats/" + item_type, query_options, item_id, "metric", text_goal,
function(item, sum) {
var mark = item.metric >= target;
return {
mark: mark,
info: mark? "Achieved metric " + item.metric:
"Metric " + item.metric + " is worse than the goal in " + target,
value: item.index
}
});
});
}
function goal_percentage_in_top_less_than(container_id, query_options, item_type, item_id, target_percentage, text_goal) {
$(document).ready(function () {
process_stats(container_id, "/api/1.0/stats/" + item_type, query_options, item_id, "metric", text_goal,
function(item, sum) {
var percentage = item.metric / sum;
var mark = percentage <= target_percentage;
var percentage_formatted = Math.round(percentage * 100) + "%";
var goal_percentage_formatted = Math.round(target_percentage * 100) + "%";
return {
mark: mark,
info: mark? "Achieved percentage " + percentage_formatted:
"Value " + percentage_formatted + " is more than the goal " + goal_percentage_formatted,
value: percentage_formatted
}
});
});
}
function goal_disagreement_ratio_less_than(container_id, query_options, item_id, target_percentage, text_goal) {
$(document).ready(function () {
process_stats(container_id, "/api/1.0/stats/engineers", query_options, item_id, "disagreement_ratio", text_goal,
function(item, sum) {
var percentage = parseFloat(item["disagreement_ratio"]);
var mark = percentage < target_percentage * 100;
var goal_percentage_formatted = Math.round(target_percentage * 100) + "%";
return {
mark: mark,
info: mark? "Achieved percentage " + item["disagreement_ratio"]:
"Value " + item["disagreement_ratio"] + " is more than the goal " + goal_percentage_formatted,
value: percentage
}
});
});
}
function goal_core_engineer_in_project(container_id, user_id, project, text_goal) {
$(document).ready(function () {
$.ajax({
url: makeURI("/api/1.0/users/" + user_id),
dataType: "jsonp",
success: function (data) {
var user = data.user;
var is_core = false;
if (user.core) {
for (var i in user.core) {
if (user.core[i][0] == project) {
is_core = true;
}
}
}
var result = {
mark: is_core,
text_goal: text_goal,
info: user.user_name + " (" + user_id + ") is " + (is_core? "": "not ") + "core engineer in " + project
};
$("#kpi_block_template").tmpl(result).appendTo("#" + container_id);
},
error: function() {
var result = {
mark: false,
text_goal: text_goal,
info: "Item " + user_id + " is not found in the stats"
};
$("#kpi_block_template").tmpl(result).appendTo("#" + container_id);
}
});
});
}
</script>
<script id="kpi_block_template" type="text/x-jquery-tmpl">
<div class="kpi_block">
<div class="kpi_marker ${(mark ? "kpi_good" : "kpi_bad")} ">${(mark ? "&#x2714;" : "&#x2716;")}</div>
@@ -164,7 +20,7 @@
<div style="margin: 2em;">
<div id="analytics_header" style="padding-bottom: 1em; border-bottom: 1px solid darkgrey;">
<span id="logo"><a href="{{ url_for('overview') }}"><img src="{{ url_for('static', filename='images/stackalytics_logo.png') }}" alt="Stackalytics" style="width: 100%; max-width: 190px;"></a></span>
<span id="slogan">| community heartbeat</span>
<span id="slogan" style="position: relative; top: -15px;">| community heartbeat</span>
</div>
{% block content %}

View File

@@ -11,29 +11,29 @@
var month_ago = Math.round(Date.now() / 1000) - 60 * 60 * 24 * 30;
goal_position_in_top("kpi_container_position", {release: "icehouse", metric: "commits", module: "openstack"},
goalPositionInTop("kpi_container_position", {release: "icehouse", metric: "commits", module: "openstack"},
"companies", "Mirantis", 5, "Be in top 5 by commits");
goal_position_in_top("kpi_container_position", {release: "icehouse", metric: "marks", module: "openstack"},
goalPositionInTop("kpi_container_position", {release: "icehouse", metric: "marks", module: "openstack"},
"engineers", "boris-42", 5, "Be in top 5 by reviews");
goal_position_in_top("kpi_container_position", {release: "icehouse", metric: "marks", module: "glance"},
goalPositionInTop("kpi_container_position", {release: "icehouse", metric: "marks", module: "glance"},
"engineers", "boris-42", 3, "Be in top 3 among reviewers");
goal_percentage_in_top_less_than("kpi_container_percentage",
goalPercentageInTopLessThan("kpi_container_percentage",
{release: "all", metric: "commits", project_type: "all", module: "stackalytics"},
"companies", "Mirantis", 0.8, "Mirantis contribution to Stackalytics is less than 80%");
goal_metric("kpi_container_position", {release: "icehouse", metric: "bpd", module: "glance"},
goalMetric("kpi_container_position", {release: "icehouse", metric: "bpd", module: "glance"},
"modules", "glance", 50, "File at least 50 blueprints into Glance");
goal_metric("kpi_container_position", {release: "all", metric: "bpd", module: "glance", start_date: month_ago},
goalMetric("kpi_container_position", {release: "all", metric: "bpd", module: "glance", start_date: month_ago},
"modules", "glance", 20, "File at least 20 blueprints into Glance in last month");
goal_disagreement_ratio_less_than("kpi_container_percentage",
goalDisagreementRatioLessThan("kpi_container_percentage",
{release: "all", metric: "marks", project_type: "all", module: "glance"},
"lzy-dev", 0.08, "Disagreement ratio should be less than 8%");
goal_core_engineer_in_project("kpi_core_status", "lzy-dev", "glance", "Become core engineer in Glance");
goalCoreEngineerInProject("kpi_core_status", "lzy-dev", "glance", "Become core engineer in Glance");
});
</script>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "kpi/base_kpi.html" %}
{% block title %}
Example of scripted KPI report
{% endblock %}
{% block scripts %}
<script type="text/javascript">
$(document).ready(function () {
readKpiScript($("#kpi_script").val(), "root_container");
$("#btn_update").click(function() {
readKpiScript($("#kpi_script").val(), "root_container");
})
});
</script>
{% endblock %}
{% block content %}
<div>KPI report can be configured by script. See the example below:</div>
<label for="kpi_script"></label>
<textarea id="kpi_script" style="width: 60em; height: 30em;">
Release Icehouse:
Company Mirantis:
- top 5 by commits in official-integrated
- less than 50% by commits in stackalytics
Group glance-group:
User lzy-dev:
- top 3 by commits
- top 4 by reviews in glance
- make 10 commits during 30 days
- become core
User apevec:
- make 15 reviews in python-glanceclient
- draft 2 blueprints
- implement 7 blueprints
- send 10 emails
Company Mirantis:
- top 5 by locs
</textarea>
<button id="btn_update">Update</button>
<div id="root_container"></div>
{% endblock %}