Skip to content

Commit

Permalink
Display the number of consecutive failed provsion attempts on the age…
Browse files Browse the repository at this point in the history
…nts page (#306)

* Display the number of consecutive failed provsion attempts on the agents page

* support both successful and failed provision streaks

* Add provision success rate to the details page

* consolidate testapp and testing_app fixtures

* Add missing closing tag
  • Loading branch information
plars authored Jul 23, 2024
1 parent dd03ec1 commit 8bd2a0f
Show file tree
Hide file tree
Showing 12 changed files with 298 additions and 26 deletions.
8 changes: 6 additions & 2 deletions server/devel/create_sample_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,14 @@ def post_agent_data(self, agents: Iterator):
)

# Add failed provision logs with obviously fake job_id for testing
exit_code = random.choice((0, 1))
exit_detail = (
"provision_fail" if exit_code != 0 else "provision_pass"
)
provision_log = {
"job_id": "00000000-0000-0000-0000-00000000000",
"exit_code": 1,
"detail": "provision_fail",
"exit_code": exit_code,
"detail": exit_detail,
}
self.session.post(
f"{self.server_url}/v1/agents/provision_logs/{agent_name}",
Expand Down
18 changes: 18 additions & 0 deletions server/src/api/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from apiflask import APIBlueprint, abort
from flask import jsonify, request, send_file
from prometheus_client import Counter

from werkzeug.exceptions import BadRequest

from src import database
Expand Down Expand Up @@ -511,6 +512,23 @@ def agents_provision_logs_post(agent_name, json_data):
update_operation,
upsert=True,
)
agent = database.mongo.db.agents.find_one(
{"name": agent_name},
{"provision_streak_type": 1, "provision_streak_count": 1},
)
if not agent:
return "Agent not found\n", 404
previous_provision_streak_type = agent.get("provision_streak_type", "")
previous_provision_streak_count = agent.get("provision_streak_count", 0)

agent["provision_streak_type"] = (
"fail" if json_data["exit_code"] != 0 else "pass"
)
if agent["provision_streak_type"] == previous_provision_streak_type:
agent["provision_streak_count"] = previous_provision_streak_count + 1
else:
agent["provision_streak_count"] = 1
database.mongo.db.agents.update_one({"name": agent_name}, {"$set": agent})
return "OK"


Expand Down
46 changes: 46 additions & 0 deletions server/src/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import os
import urllib
from datetime import datetime
from typing import Any

from flask_pymongo import PyMongo
Expand Down Expand Up @@ -196,3 +197,48 @@ def pop_job(queue_list):
job_id = response["job_id"]
job["job_id"] = job_id
return job


def get_provision_log(
agent_id: str, start_datetime: datetime, stop_datetime: datetime
) -> list:
"""Get the provision log for an agent between two dates"""
provision_log_entries = mongo.db.provision_logs.aggregate(
[
{"$match": {"name": agent_id}},
{
"$project": {
"provision_log": {
"$filter": {
"input": "$provision_log",
"as": "log",
"cond": {
"$and": [
{
"$gte": [
"$$log.timestamp",
start_datetime,
]
},
{
"$lt": [
"$$log.timestamp",
stop_datetime,
]
},
]
},
}
}
}
},
]
)
# Convert the aggregated result to a list of logs
provision_log_entries = list(provision_log_entries)

return (
provision_log_entries[0]["provision_log"]
if provision_log_entries
else []
)
95 changes: 95 additions & 0 deletions server/src/static/assets/js/filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,98 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
});

function sortTable(header, table) {
var SORTABLE_STATES = {
none: 0,
ascending: -1,
descending: 1,
ORDER: ['none', 'ascending', 'descending'],
};

// Get index of column based on position of header cell in <thead>
// We assume there is only one row in the table head.
var col = [].slice.call(table.tHead.rows[0].cells).indexOf(header);

// Based on the current aria-sort value, get the next state.
var newOrder = SORTABLE_STATES.ORDER.indexOf(header.getAttribute('aria-sort')) + 1;
newOrder = newOrder > SORTABLE_STATES.ORDER.length - 1 ? 0 : newOrder;
newOrder = SORTABLE_STATES.ORDER[newOrder];

// Reset all header sorts.
var headerSorts = table.querySelectorAll('[aria-sort]');

for (var i = 0, ii = headerSorts.length; i < ii; i += 1) {
headerSorts[i].setAttribute('aria-sort', 'none');
}

// Set the new header sort.
header.setAttribute('aria-sort', newOrder);

// Get the direction of the sort and assume only one tbody.
// For this example only assume one tbody.
var direction = SORTABLE_STATES[newOrder];
var body = table.tBodies[0];

// Convert the HTML element list to an array.
var newRows = [].slice.call(body.rows, 0);

// If the direction is 0 - aria-sort="none".
if (direction === 0) {
// Reset to the default order.
newRows.sort(function (a, b) {
return a.getAttribute('data-index') - b.getAttribute('data-index');
});
} else {
// Sort based on a cell contents
newRows.sort(function (rowA, rowB) {
// Trim the cell contents.
var contentA = rowA.cells[col].textContent.trim();
var contentB = rowB.cells[col].textContent.trim();

// Based on the direction, do the sort.
//
// This example only sorts based on alphabetical order, to sort based on
// number value a more specific implementation would be needed, to provide
// number parsing and comparison function between text strings and numbers.
return contentA < contentB ? direction : -direction;
});
}
// Append each row into the table, replacing the current elements.
for (i = 0, ii = body.rows.length; i < ii; i += 1) {
body.appendChild(newRows[i]);
}
}

function setupClickableHeader(table, header) {
header.addEventListener('click', function () {
sortTable(header, table);
});
}

/**
* Initializes a sortable table by assigning event listeners to sortable column headers.
* @param {HTMLTableElement} table
*/
function setupSortableTable(table) {
// For this example, assume only one tbody.
var rows = table.tBodies[0].rows;
// Set an index for the default order.
for (var row = 0, totalRows = rows.length; row < totalRows; row += 1) {
rows[row].setAttribute('data-index', row);
}

// Select sortable column headers.
var clickableHeaders = table.querySelectorAll('th[aria-sort]');
// Attach the click event for each header.
for (var i = 0, ii = clickableHeaders.length; i < ii; i += 1) {
setupClickableHeader(table, clickableHeaders[i]);
}
}

// Make all tables on the page sortable.
var tables = document.querySelectorAll('table');

for (var i = 0, ii = tables.length; i < ii; i += 1) {
setupSortableTable(tables[i]);
}
18 changes: 18 additions & 0 deletions server/src/templates/agent_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ <h2 class="p-muted-heading">Queues</h2>
</ul>

<h2 class="p-muted-heading">Provision History</h2>
<form action="" method="get" class="p-form--inline">
<div class="p-form__group">
<label for="start-date" class="p-form__label">Start Date</label>
<input type="date" id="start-date" name="start" class="p-form__control"
value="{{ request.args.get('start', agent.start) }}">
</div>
<div class="p-form__group">
<label for="stop-date" class="p-form__label">Stop Date</label>
<input type="date" id="stop-date" name="stop" class="p-form__control"
value="{{ request.args.get('stop', agent.stop) }}">
</div>
<button type="submit" class="p-button--positive">Refresh</button>
</form>

<div>
<strong>Provision success rate for this range:</strong> {{ agent.provision_success_rate }}%
</div>

<div>
<table>
<thead>
Expand Down
25 changes: 22 additions & 3 deletions server/src/templates/agents.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,34 @@ <h1 class="p-heading--3">
<table aria-label="Agents table" class="p-table--mobile-card">
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th>Updated At</th>
<th aria-sort="none" class="has-overflow" style="width: 40pt"></th>
<!-- Added an empty header for the warning icon column -->
<th aria-sort="none">Name</th>
<th aria-sort="none">State</th>
<th aria-sort="none">Updated At</th>
<th>Job ID</th>
</tr>
</thead>
<tbody>
{% for agent in agents %}
<tr class="searchable-row">
<td class="has-overflow">
{% if agent.provision_streak_type == "fail" and agent.provision_streak_count > 0 %}
<span class="p-tooltip--right" aria-describedby="tooltip">
<i class="p-icon--warning"></i>
<span class="p-tooltip__message" role="tooltip" id="tooltip">This agent has failed the last {{
agent.provision_streak_count }} provision attempts</span>
</span>
{{ agent.provision_streak_count }}
{% elif agent.provision_streak_type == "pass" and agent.provision_streak_count > 0 %}
<span class="p-tooltip--right" aria-describedby="tooltip">
<i class="p-icon--success"></i>
<span class="p-tooltip__message" role="tooltip" id="tooltip">This agent has passed the last {{
agent.provision_streak_count }} provision attempts</span>
</span>
{{ agent.provision_streak_count }}
{% endif %}
</td>
<td><a href="/agents/{{ agent.name }}">{{ agent.name }}</a></td>
<td>{{ agent.state }}</td>
<td>{{ agent.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
Expand Down
4 changes: 2 additions & 2 deletions server/src/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<title>{% block title %} {% endblock %} - Testflinger</title>
<!-- Site CSS Files -->
<link href="/static/assets/css/testflinger.css" rel="stylesheet" />
<link rel="stylesheet" href="https://assets.ubuntu.com/v1/vanilla-framework-version-4.5.0.min.css" />
<link rel="stylesheet" href="https://assets.ubuntu.com/v1/vanilla-framework-version-4.13.0.min.css" />
<!-- Don't request favicon -->
<link rel="icon" href="data:,">
<!-- Page specific CSS Files -->
Expand Down Expand Up @@ -73,4 +73,4 @@
<script type="text/javascript" src="/static/assets/js/filter.js"></script>
</body>

</html>
</html>
56 changes: 50 additions & 6 deletions server/src/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,17 @@
Additional views not associated with the API
"""

from flask import Blueprint, make_response, render_template, redirect, url_for
from datetime import datetime, timedelta
from flask import (
Blueprint,
make_response,
render_template,
redirect,
request,
url_for,
)
from prometheus_client import generate_latest
from src import database
from src.database import mongo

views = Blueprint("testflinger", __name__)
Expand Down Expand Up @@ -46,20 +55,55 @@ def agents():
@views.route("/agents/<agent_id>")
def agent_detail(agent_id):
"""Agent detail view"""
default_start_date = (datetime.now() - timedelta(days=2)).strftime(
"%Y-%m-%d"
)
default_stop_date = datetime.now().strftime("%Y-%m-%d")

start_date = request.args.get("start", default_start_date)
stop_date = request.args.get("stop", default_stop_date)

# Convert start and stop dates to datetime objects for the query
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
stop_datetime = datetime.strptime(stop_date, "%Y-%m-%d") + timedelta(
days=1
)

agent_info = mongo.db.agents.find_one({"name": agent_id})
if not agent_info:
response = make_response(
render_template("agent_not_found.html", agent_id=agent_id)
)
response.status_code = 404
return response
provision_log_entry = mongo.db.provision_logs.find_one(
{"name": agent_id}, {"provision_log": 1}
)
agent_info["provision_log"] = (
provision_log_entry["provision_log"] if provision_log_entry else []

# We want to include the start/stop dates so that default values
# can be filled in for the date pickers
agent_info["start"] = start_date
agent_info["stop"] = stop_date

agent_info["provision_log"] = database.get_provision_log(
agent_id,
start_datetime=start_datetime,
stop_datetime=stop_datetime,
)

if agent_info["provision_log"]:
agent_info["provision_success_rate"] = int(
100
* len(
[
entry
for entry in agent_info["provision_log"]
if entry["exit_code"] == 0
]
)
/ len(agent_info["provision_log"])
)
else:
# Avoid division by zero
agent_info["provision_success_rate"] = 0

return render_template("agent_detail.html", agent=agent_info)


Expand Down
7 changes: 0 additions & 7 deletions server/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,3 @@ def testapp():
"""pytest fixture for just the app"""
app = application.create_flask_app(TestingConfig)
yield app


@pytest.fixture
def testing_app():
"""Create an app for testing without using test_client"""
app = application.create_flask_app(TestingConfig)
yield app
4 changes: 2 additions & 2 deletions server/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
from src.application import create_flask_app


def test_default_config(testing_app):
def test_default_config(testapp):
"""Test default config settings"""
app = testing_app
app = testapp
assert app.config.get("PROPAGATE_EXCEPTIONS") is True


Expand Down
Loading

0 comments on commit 8bd2a0f

Please sign in to comment.