Skip to content

Commit

Permalink
Dynamic HTML output for monitoring (#2062)
Browse files Browse the repository at this point in the history
Co-authored-by: Klaus Zimmermann <[email protected]>
  • Loading branch information
bsolino and Klaus Zimmermann authored Oct 9, 2023
1 parent 0b07b05 commit be1b790
Show file tree
Hide file tree
Showing 7 changed files with 339 additions and 30 deletions.
41 changes: 40 additions & 1 deletion esmvalcore/experimental/recipe_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import base64
import logging
import os.path
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Optional, Tuple, Type

Expand Down Expand Up @@ -123,6 +123,13 @@ class RecipeOutput(Mapping):
The session used to run the recipe.
"""

FILTER_ATTRS: list = [
"realms",
"plot_type", # Used by several diagnostics
"plot_types",
"long_names",
]

def __init__(self, task_output: dict, session=None, info=None):
self._raw_task_output = task_output
self._task_output = {}
Expand All @@ -141,6 +148,7 @@ def __init__(self, task_output: dict, session=None, info=None):
diagnostics[name].append(task)

# Create diagnostic output
filters: dict = {}
for name, tasks in diagnostics.items():
diagnostic_info = info.data['diagnostics'][name]
self.diagnostics[name] = DiagnosticOutput(
Expand All @@ -150,6 +158,36 @@ def __init__(self, task_output: dict, session=None, info=None):
description=diagnostic_info.get('description'),
)

# Add data to filters
for task in tasks:
for file in task.files:
RecipeOutput._add_to_filters(filters, file.attributes)

# Sort at the end because sets are unordered
self.filters = RecipeOutput._sort_filters(filters)

@classmethod
def _add_to_filters(cls, filters, attributes):
"""Add valid values to the HTML output filters."""
for attr in RecipeOutput.FILTER_ATTRS:
if attr not in attributes:
continue
values = attributes[attr]
# `set()` to avoid duplicates
attr_list = filters.get(attr, set())
if (isinstance(values, str) or not isinstance(values, Sequence)):
attr_list.add(values)
else:
attr_list.update(values)
filters[attr] = attr_list

@classmethod
def _sort_filters(cls, filters):
"""Sort the HTML output filters."""
for _filter, _attrs in filters.items():
filters[_filter] = sorted(_attrs)
return filters

def __repr__(self):
"""Return canonical string representation."""
string = '\n'.join(repr(item) for item in self._task_output.values())
Expand Down Expand Up @@ -218,6 +256,7 @@ def render(self, template=None):
diagnostics=self.diagnostics.values(),
session=self.session,
info=self.info,
filters=self.filters,
relpath=os.path.relpath,
)

Expand Down
70 changes: 65 additions & 5 deletions esmvalcore/experimental/templates/RecipeOutput.j2
Original file line number Diff line number Diff line change
@@ -1,12 +1,72 @@

<!-- Tab links -->
<ul class="nav nav-tabs sticky-top bg-light" id="tabDiagnostics" role="tablist">
<li class="nav-item">
<!-- Filter -->
<div class="dropdown" style="position: static">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-filter" viewBox="0 0 16 16">
<path d="M6 10.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5zm-2-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm-2-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
</svg>
Filters
</button>
<div class="dropdown-menu w-100">
<div class="container div_filter">
<div class="row justify-content-center">
{% for filter_name, filter_values in filters.items() %}
{% set filter_loop = loop %}
<div class="col-xl-3 col-lg-4 col-sm-6 col-12 filter_category" id="filter_{{ filter_name|replace(' ', '_') }}">
<h4>{{ filter_name|replace('_', ' ')|title }}</h4>
{% for value in filter_values %}
<div class="form-check">
<input class="form-check-input filter_cb" type="checkbox" value="" id="cb_{{ filter_loop.index }}_{{ loop.index }}" rel="f_{{ filter_name|replace(' ', '_') }}_{{ value|replace(' ', '_') }}">
<label class="form-check-label" for="cb_{{ filter_loop.index }}_{{ loop.index }}">
{{ value|replace('_', ' ')|title }}
</label>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<div class="d-flex justify-content-center align-items-center gap-3">
<div class="d-inline-block">
<button class="btn btn-primary" id="b_deleteFilters" disabled>Delete Filters</button>
</div>
<div class="form-check form-switch d-inline-block">
<input class="form-check-input" type="checkbox" value="" id="cb_hideEmptyDiagnostics" rel="" checked>
<label class="form-check-label" for="cb_hideEmptyDiagnostics">
Hide empty diagnostics
</label>
</div>
</div>
</div>
</div>
</div>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link active diagnostics-tab" id="tabAll" data-bs-toggle="tab" data-bs-target="#" type="button" role="tab" aria-controls="" aria-selected="true">All</button>
</li>
{% for diagnostic in diagnostics %}
<li class="nav-item" role="presentation">
<button class="nav-link diagnostics-tab" id="tab_{{ loop.index }}" data-bs-toggle="tab" data-bs-target="#tabPane_{{ loop.index }}" type="button" role="tab" aria-controls="tabPane_{{ loop.index }}" aria-selected="true">{{ diagnostic.title }}</button>
</li>
{% endfor %}
</ul>

<div class="tab-content" id="tabContentDiagnostics">
{% for diagnostic in diagnostics %}

<h2>{{ diagnostic.title }}</h2>
<p>{{ diagnostic.description }}</p>
<div id="tabPane_{{ loop.index }}" class="tab-pane show active diagnostics-tab-pane" role="tabpanel" aria-labelledby="tab_{{ loop.index }}">
<h2>{{ diagnostic.title }}</h2>
<p>{{ diagnostic.description }}</p>

{% for task in diagnostic.task_output %}
{% set diagnostic_loop = loop %}
{% for task in diagnostic.task_output %}

{% include 'TaskOutput.j2' %}
{% include 'TaskOutput.j2' %}

{% endfor %}
{% endfor %}
</div>

{% endfor %}
</div>
49 changes: 34 additions & 15 deletions esmvalcore/experimental/templates/TaskOutput.j2
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,23 @@

{% for file in task.image_files %}

<figure>
<div class="div_figure d-inline-flex
{% for filter_name in filters.keys() %}
{% if filter_name in file.attributes %}
{% set attribute = file.attributes[filter_name] %}
{% if attribute is string or not (attribute is iterable) %}
f_{{ filter_name|replace(' ', '_') }}_{{ attribute | replace(' ', '_') }}
{% else %}
{% for attr in attribute %} f_{{ filter_name|replace(' ', '_') }}_{{ attr | replace(' ', '_') }} {% endfor %}
{% endif %}
{% endif %}
{% endfor %}
">
<figure class="figure">
<a href='{{ relpath(file.path, session.session_dir) }}'>
<img src='{{ relpath(file.path, session.session_dir) }}' alt='{{ file.caption }}'/>
<img class="figure-img img-fluid" src='{{ relpath(file.path, session.session_dir) }}' alt='{{ file.caption }}'/>
</a>
<figcaption>
<figcaption class="figure-caption">
{{ file.caption }}
<br>
<br>
Expand All @@ -16,21 +28,28 @@
<a href='{{ relpath(file.provenance_xml_file, session.session_dir) }}'>provenance</a>
</figcaption>
</figure>
</div>

{% endfor %}

<h4>Data files</h4>
{% if task.data_files|length > 0 %}
<h4>Data files <button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#df_{{ diagnostic_loop.index }}_{{ loop.index }}" aria-expanded="false" aria-controls="df_{{ diagnostic_loop.index }}_{{ loop.index }}">Show/Hide</button></h4>

<ul>
{% for file in task.data_files %}
<div id="df_{{ diagnostic_loop.index }}_{{ loop.index }}" class="collapse">
<div class="card card-body">
<ul>
{% for file in task.data_files %}

<li>
{{ file.caption }} |
<a href='{{ relpath(file.path, session.session_dir) }}'>download</a> |
<a href='{{ relpath(file.citation_file, session.session_dir) }}'>references</a> |
<a href='{{ relpath(file.data_citation_file, session.session_dir) }}'>extra data citation</a> |
<a href='{{ relpath(file.provenance_xml_file, session.session_dir) }}'>provenance</a>
</li>
<li>
{{ file.caption }} |
<a href='{{ relpath(file.path, session.session_dir) }}'>download</a> |
<a href='{{ relpath(file.citation_file, session.session_dir) }}'>references</a> |
<a href='{{ relpath(file.data_citation_file, session.session_dir) }}'>extra data citation</a> |
<a href='{{ relpath(file.provenance_xml_file, session.session_dir) }}'>provenance</a>
</li>

{% endfor %}
</ul>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
3 changes: 3 additions & 0 deletions esmvalcore/experimental/templates/head.j2
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-HwwvtgBNo3bZJJLYd8oVXjrBZt8cqVSpeBNS5n7C8IVInixGAoxmnlMuBnhbgrkm" crossorigin="anonymous"></script>
<style>
html {
font-size: medium;
Expand Down
32 changes: 23 additions & 9 deletions esmvalcore/experimental/templates/recipe_output_page.j2
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,34 @@

<body>

<div class="text-center">
<figure class="figure">
<img
class="figure-img img-fluid"
src='https://raw.githubusercontent.com/ESMValGroup/ESMValTool/main/doc/sphinx/source/figures/ESMValTool-logo-2.png'
alt='ESMValTool logo.'
/>
</figure>
</div>

{% include 'RecipeInfo.j2' %}

{% include 'RecipeOutput.j2' %}

</body>
<h2>Files</h2>

<h2>Files</h2>
<p>
<a href='{{ session.relative_main_log }}'>{{ session.main_log.name }}</a> |
<a href='{{ session.relative_main_log_debug }}'>{{ session.main_log_debug.name }}</a> |
<a href='{{ session.relative_run_dir / info.filename }}'>{{ info.filename }}</a> |
<a href='{{ session.relative_plot_dir }}'>figures</a> |
<a href='{{ session.relative_work_dir }}'>data</a>
</p>

<p>
<a href='{{ session.relative_main_log }}'>{{ session.main_log.name }}</a> |
<a href='{{ session.relative_main_log_debug }}'>{{ session.main_log_debug.name }}</a> |
<a href='{{ session.relative_run_dir / info.filename }}'>{{ info.filename }}</a> |
<a href='{{ session.relative_plot_dir }}'>figures</a> |
<a href='{{ session.relative_work_dir }}'>data</a>
</p>
<script>
{% include 'scripts.js' %}
</script>

</body>

</html>
122 changes: 122 additions & 0 deletions esmvalcore/experimental/templates/scripts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
function filterFigures(){
/**
* Update visibility of filtered figures.
*/
let allFigures = $(".div_figure");
let selectedFigures = allFigures;
$(".filter_category").each(function() {
let selection = $(this).find(":checked").map(function() {
// Returns the figures that the checkbox relates to.
return $("."+$(this).attr("rel")).get();
});
if (selection.length !== 0){
selectedFigures = selectedFigures.filter(selection);
}
});
selectedFigures.addClass("selected") // affects the div
.find("figure").show(); // affects figure inside the div
allFigures.not(selectedFigures).removeClass("selected") // affects the div
.find("figure").hide(); // affects figure inside the div
}

function filterTabs(){
/**
* Disable tab buttons for empty diagnostics and
* mark empty tabPanes.
*/
$(".diagnostics-tab").not("#tabAll").each(function() {
let tabPane = $($(this).attr("data-bs-target"));
if (tabPane.find(".div_figure.selected").length === 0){
$(this).addClass("disabled");
tabPane.addClass("filtered");
} else {
$(this).removeClass("disabled");
tabPane.removeClass("filtered");
}

// If the active tab is disabled, change to "All"
if($(".diagnostics-tab.active").hasClass("disabled")){
$("#tabAll").click();
}
});
}

function hideEmptyTabPanes(){
/**
* Hide empty tab panes. It's separated from "filterTabs()"
* to reuse on the "Hide empty diagnostics" checkbox
*/
if($("#tabAll").hasClass("active")){
let panes = $(".diagnostics-tab-pane");
panes.addClass("active").addClass("show");
if ($("#cb_hideEmptyDiagnostics").prop("checked")){
panes.filter(".filtered").removeClass("active").removeClass("show");
}
}
}

function applyFilters(){
/**
* Updates visibility according to filters.
*/
filterFigures();
filterTabs();
hideEmptyTabPanes();
}

// Set up events with jQuery
// Specific events are defined as anonymous functions
$(document).ready(function() {

$("#tabAll").on("click", function() {
/**
* Functionality for tab "All", as it is not supported
* by Bootstrap.
*/

// Both activate this tab
$(this).addClass("active")
// and deactivate other tabs
.parent("li").siblings().find("button").removeClass("active");

// Show all non-filtered tab panes
let tabPanes = $(".diagnostics-tab-pane");
if ($("#cb_hideEmptyDiagnostics").prop("checked")){
tabPanes = tabPanes.not(".filtered");
}
tabPanes.addClass("active").addClass("show");
});

$(".diagnostics-tab").not("#tabAll").on("click", function() {
/**
* Upgrades Bootstrap tab functionality to deactivate
* tab "All" by hiding all non-selected panes, as
* Bootstrap hides only one pane.
*/
$(".diagnostics-tab-pane").not($(this).attr("data-bs-target"))
.removeClass("active").removeClass("show");
});

// Checkbox "Hide empty diagnostics"
$("#cb_hideEmptyDiagnostics").on("click", hideEmptyTabPanes);

$("#b_deleteFilters").on("click", function(){
/**
* Unchecks all filters and disables "Delete filters" button.
*/
$(".filter_cb").prop("checked", false);
applyFilters();
$(this).prop("disabled", true);
});

$(".filter_cb").on("click", function(){
/**
* Update visibility of figures and panes when filters
* are applied, and set up disable filters button.
*/
applyFilters();

let areFiltersClear = $(".filter_cb:checked").length === 0;
$("#b_deleteFilters").prop("disabled", areFiltersClear);
});
});
Loading

0 comments on commit be1b790

Please sign in to comment.