Skip to content

Commit be1b790

Browse files
bsolinoKlaus Zimmermann
and
Klaus Zimmermann
authored
Dynamic HTML output for monitoring (#2062)
Co-authored-by: Klaus Zimmermann <klaus.zimmermann@smhi.se>
1 parent 0b07b05 commit be1b790

File tree

7 files changed

+339
-30
lines changed

7 files changed

+339
-30
lines changed

esmvalcore/experimental/recipe_output.py

+40-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import base64
33
import logging
44
import os.path
5-
from collections.abc import Mapping
5+
from collections.abc import Mapping, Sequence
66
from pathlib import Path
77
from typing import Optional, Tuple, Type
88

@@ -123,6 +123,13 @@ class RecipeOutput(Mapping):
123123
The session used to run the recipe.
124124
"""
125125

126+
FILTER_ATTRS: list = [
127+
"realms",
128+
"plot_type", # Used by several diagnostics
129+
"plot_types",
130+
"long_names",
131+
]
132+
126133
def __init__(self, task_output: dict, session=None, info=None):
127134
self._raw_task_output = task_output
128135
self._task_output = {}
@@ -141,6 +148,7 @@ def __init__(self, task_output: dict, session=None, info=None):
141148
diagnostics[name].append(task)
142149

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

161+
# Add data to filters
162+
for task in tasks:
163+
for file in task.files:
164+
RecipeOutput._add_to_filters(filters, file.attributes)
165+
166+
# Sort at the end because sets are unordered
167+
self.filters = RecipeOutput._sort_filters(filters)
168+
169+
@classmethod
170+
def _add_to_filters(cls, filters, attributes):
171+
"""Add valid values to the HTML output filters."""
172+
for attr in RecipeOutput.FILTER_ATTRS:
173+
if attr not in attributes:
174+
continue
175+
values = attributes[attr]
176+
# `set()` to avoid duplicates
177+
attr_list = filters.get(attr, set())
178+
if (isinstance(values, str) or not isinstance(values, Sequence)):
179+
attr_list.add(values)
180+
else:
181+
attr_list.update(values)
182+
filters[attr] = attr_list
183+
184+
@classmethod
185+
def _sort_filters(cls, filters):
186+
"""Sort the HTML output filters."""
187+
for _filter, _attrs in filters.items():
188+
filters[_filter] = sorted(_attrs)
189+
return filters
190+
153191
def __repr__(self):
154192
"""Return canonical string representation."""
155193
string = '\n'.join(repr(item) for item in self._task_output.values())
@@ -218,6 +256,7 @@ def render(self, template=None):
218256
diagnostics=self.diagnostics.values(),
219257
session=self.session,
220258
info=self.info,
259+
filters=self.filters,
221260
relpath=os.path.relpath,
222261
)
223262

Original file line numberDiff line numberDiff line change
@@ -1,12 +1,72 @@
1+
2+
<!-- Tab links -->
3+
<ul class="nav nav-tabs sticky-top bg-light" id="tabDiagnostics" role="tablist">
4+
<li class="nav-item">
5+
<!-- Filter -->
6+
<div class="dropdown" style="position: static">
7+
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
8+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-filter" viewBox="0 0 16 16">
9+
<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"/>
10+
</svg>
11+
Filters
12+
</button>
13+
<div class="dropdown-menu w-100">
14+
<div class="container div_filter">
15+
<div class="row justify-content-center">
16+
{% for filter_name, filter_values in filters.items() %}
17+
{% set filter_loop = loop %}
18+
<div class="col-xl-3 col-lg-4 col-sm-6 col-12 filter_category" id="filter_{{ filter_name|replace(' ', '_') }}">
19+
<h4>{{ filter_name|replace('_', ' ')|title }}</h4>
20+
{% for value in filter_values %}
21+
<div class="form-check">
22+
<input class="form-check-input filter_cb" type="checkbox" value="" id="cb_{{ filter_loop.index }}_{{ loop.index }}" rel="f_{{ filter_name|replace(' ', '_') }}_{{ value|replace(' ', '_') }}">
23+
<label class="form-check-label" for="cb_{{ filter_loop.index }}_{{ loop.index }}">
24+
{{ value|replace('_', ' ')|title }}
25+
</label>
26+
</div>
27+
{% endfor %}
28+
</div>
29+
{% endfor %}
30+
</div>
31+
<div class="d-flex justify-content-center align-items-center gap-3">
32+
<div class="d-inline-block">
33+
<button class="btn btn-primary" id="b_deleteFilters" disabled>Delete Filters</button>
34+
</div>
35+
<div class="form-check form-switch d-inline-block">
36+
<input class="form-check-input" type="checkbox" value="" id="cb_hideEmptyDiagnostics" rel="" checked>
37+
<label class="form-check-label" for="cb_hideEmptyDiagnostics">
38+
Hide empty diagnostics
39+
</label>
40+
</div>
41+
</div>
42+
</div>
43+
</div>
44+
</div>
45+
</li>
46+
<li class="nav-item" role="presentation">
47+
<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>
48+
</li>
49+
{% for diagnostic in diagnostics %}
50+
<li class="nav-item" role="presentation">
51+
<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>
52+
</li>
53+
{% endfor %}
54+
</ul>
55+
56+
<div class="tab-content" id="tabContentDiagnostics">
157
{% for diagnostic in diagnostics %}
258

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

6-
{% for task in diagnostic.task_output %}
63+
{% set diagnostic_loop = loop %}
64+
{% for task in diagnostic.task_output %}
765

8-
{% include 'TaskOutput.j2' %}
66+
{% include 'TaskOutput.j2' %}
967

10-
{% endfor %}
68+
{% endfor %}
69+
</div>
1170

1271
{% endfor %}
72+
</div>

esmvalcore/experimental/templates/TaskOutput.j2

+34-15
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,23 @@
22

33
{% for file in task.image_files %}
44

5-
<figure>
5+
<div class="div_figure d-inline-flex
6+
{% for filter_name in filters.keys() %}
7+
{% if filter_name in file.attributes %}
8+
{% set attribute = file.attributes[filter_name] %}
9+
{% if attribute is string or not (attribute is iterable) %}
10+
f_{{ filter_name|replace(' ', '_') }}_{{ attribute | replace(' ', '_') }}
11+
{% else %}
12+
{% for attr in attribute %} f_{{ filter_name|replace(' ', '_') }}_{{ attr | replace(' ', '_') }} {% endfor %}
13+
{% endif %}
14+
{% endif %}
15+
{% endfor %}
16+
">
17+
<figure class="figure">
618
<a href='{{ relpath(file.path, session.session_dir) }}'>
7-
<img src='{{ relpath(file.path, session.session_dir) }}' alt='{{ file.caption }}'/>
19+
<img class="figure-img img-fluid" src='{{ relpath(file.path, session.session_dir) }}' alt='{{ file.caption }}'/>
820
</a>
9-
<figcaption>
21+
<figcaption class="figure-caption">
1022
{{ file.caption }}
1123
<br>
1224
<br>
@@ -16,21 +28,28 @@
1628
<a href='{{ relpath(file.provenance_xml_file, session.session_dir) }}'>provenance</a>
1729
</figcaption>
1830
</figure>
31+
</div>
1932

2033
{% endfor %}
2134

22-
<h4>Data files</h4>
35+
{% if task.data_files|length > 0 %}
36+
<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>
2337

24-
<ul>
25-
{% for file in task.data_files %}
38+
<div id="df_{{ diagnostic_loop.index }}_{{ loop.index }}" class="collapse">
39+
<div class="card card-body">
40+
<ul>
41+
{% for file in task.data_files %}
2642

27-
<li>
28-
{{ file.caption }} |
29-
<a href='{{ relpath(file.path, session.session_dir) }}'>download</a> |
30-
<a href='{{ relpath(file.citation_file, session.session_dir) }}'>references</a> |
31-
<a href='{{ relpath(file.data_citation_file, session.session_dir) }}'>extra data citation</a> |
32-
<a href='{{ relpath(file.provenance_xml_file, session.session_dir) }}'>provenance</a>
33-
</li>
43+
<li>
44+
{{ file.caption }} |
45+
<a href='{{ relpath(file.path, session.session_dir) }}'>download</a> |
46+
<a href='{{ relpath(file.citation_file, session.session_dir) }}'>references</a> |
47+
<a href='{{ relpath(file.data_citation_file, session.session_dir) }}'>extra data citation</a> |
48+
<a href='{{ relpath(file.provenance_xml_file, session.session_dir) }}'>provenance</a>
49+
</li>
3450

35-
{% endfor %}
36-
</ul>
51+
{% endfor %}
52+
</ul>
53+
</div>
54+
</div>
55+
{% endif %}

esmvalcore/experimental/templates/head.j2

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
<meta charset="UTF-8">
33
<meta name="viewport" content="width=device-width, initial-scale=1.0">
44
<title>{{ title }}</title>
5+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
6+
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js"></script>
7+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-HwwvtgBNo3bZJJLYd8oVXjrBZt8cqVSpeBNS5n7C8IVInixGAoxmnlMuBnhbgrkm" crossorigin="anonymous"></script>
58
<style>
69
html {
710
font-size: medium;

esmvalcore/experimental/templates/recipe_output_page.j2

+23-9
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,34 @@
77

88
<body>
99

10+
<div class="text-center">
11+
<figure class="figure">
12+
<img
13+
class="figure-img img-fluid"
14+
src='https://raw.githubusercontent.com/ESMValGroup/ESMValTool/main/doc/sphinx/source/figures/ESMValTool-logo-2.png'
15+
alt='ESMValTool logo.'
16+
/>
17+
</figure>
18+
</div>
19+
1020
{% include 'RecipeInfo.j2' %}
1121

1222
{% include 'RecipeOutput.j2' %}
1323

14-
</body>
24+
<h2>Files</h2>
1525

16-
<h2>Files</h2>
26+
<p>
27+
<a href='{{ session.relative_main_log }}'>{{ session.main_log.name }}</a> |
28+
<a href='{{ session.relative_main_log_debug }}'>{{ session.main_log_debug.name }}</a> |
29+
<a href='{{ session.relative_run_dir / info.filename }}'>{{ info.filename }}</a> |
30+
<a href='{{ session.relative_plot_dir }}'>figures</a> |
31+
<a href='{{ session.relative_work_dir }}'>data</a>
32+
</p>
1733

18-
<p>
19-
<a href='{{ session.relative_main_log }}'>{{ session.main_log.name }}</a> |
20-
<a href='{{ session.relative_main_log_debug }}'>{{ session.main_log_debug.name }}</a> |
21-
<a href='{{ session.relative_run_dir / info.filename }}'>{{ info.filename }}</a> |
22-
<a href='{{ session.relative_plot_dir }}'>figures</a> |
23-
<a href='{{ session.relative_work_dir }}'>data</a>
24-
</p>
34+
<script>
35+
{% include 'scripts.js' %}
36+
</script>
37+
38+
</body>
2539

2640
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
function filterFigures(){
2+
/**
3+
* Update visibility of filtered figures.
4+
*/
5+
let allFigures = $(".div_figure");
6+
let selectedFigures = allFigures;
7+
$(".filter_category").each(function() {
8+
let selection = $(this).find(":checked").map(function() {
9+
// Returns the figures that the checkbox relates to.
10+
return $("."+$(this).attr("rel")).get();
11+
});
12+
if (selection.length !== 0){
13+
selectedFigures = selectedFigures.filter(selection);
14+
}
15+
});
16+
selectedFigures.addClass("selected") // affects the div
17+
.find("figure").show(); // affects figure inside the div
18+
allFigures.not(selectedFigures).removeClass("selected") // affects the div
19+
.find("figure").hide(); // affects figure inside the div
20+
}
21+
22+
function filterTabs(){
23+
/**
24+
* Disable tab buttons for empty diagnostics and
25+
* mark empty tabPanes.
26+
*/
27+
$(".diagnostics-tab").not("#tabAll").each(function() {
28+
let tabPane = $($(this).attr("data-bs-target"));
29+
if (tabPane.find(".div_figure.selected").length === 0){
30+
$(this).addClass("disabled");
31+
tabPane.addClass("filtered");
32+
} else {
33+
$(this).removeClass("disabled");
34+
tabPane.removeClass("filtered");
35+
}
36+
37+
// If the active tab is disabled, change to "All"
38+
if($(".diagnostics-tab.active").hasClass("disabled")){
39+
$("#tabAll").click();
40+
}
41+
});
42+
}
43+
44+
function hideEmptyTabPanes(){
45+
/**
46+
* Hide empty tab panes. It's separated from "filterTabs()"
47+
* to reuse on the "Hide empty diagnostics" checkbox
48+
*/
49+
if($("#tabAll").hasClass("active")){
50+
let panes = $(".diagnostics-tab-pane");
51+
panes.addClass("active").addClass("show");
52+
if ($("#cb_hideEmptyDiagnostics").prop("checked")){
53+
panes.filter(".filtered").removeClass("active").removeClass("show");
54+
}
55+
}
56+
}
57+
58+
function applyFilters(){
59+
/**
60+
* Updates visibility according to filters.
61+
*/
62+
filterFigures();
63+
filterTabs();
64+
hideEmptyTabPanes();
65+
}
66+
67+
// Set up events with jQuery
68+
// Specific events are defined as anonymous functions
69+
$(document).ready(function() {
70+
71+
$("#tabAll").on("click", function() {
72+
/**
73+
* Functionality for tab "All", as it is not supported
74+
* by Bootstrap.
75+
*/
76+
77+
// Both activate this tab
78+
$(this).addClass("active")
79+
// and deactivate other tabs
80+
.parent("li").siblings().find("button").removeClass("active");
81+
82+
// Show all non-filtered tab panes
83+
let tabPanes = $(".diagnostics-tab-pane");
84+
if ($("#cb_hideEmptyDiagnostics").prop("checked")){
85+
tabPanes = tabPanes.not(".filtered");
86+
}
87+
tabPanes.addClass("active").addClass("show");
88+
});
89+
90+
$(".diagnostics-tab").not("#tabAll").on("click", function() {
91+
/**
92+
* Upgrades Bootstrap tab functionality to deactivate
93+
* tab "All" by hiding all non-selected panes, as
94+
* Bootstrap hides only one pane.
95+
*/
96+
$(".diagnostics-tab-pane").not($(this).attr("data-bs-target"))
97+
.removeClass("active").removeClass("show");
98+
});
99+
100+
// Checkbox "Hide empty diagnostics"
101+
$("#cb_hideEmptyDiagnostics").on("click", hideEmptyTabPanes);
102+
103+
$("#b_deleteFilters").on("click", function(){
104+
/**
105+
* Unchecks all filters and disables "Delete filters" button.
106+
*/
107+
$(".filter_cb").prop("checked", false);
108+
applyFilters();
109+
$(this).prop("disabled", true);
110+
});
111+
112+
$(".filter_cb").on("click", function(){
113+
/**
114+
* Update visibility of figures and panes when filters
115+
* are applied, and set up disable filters button.
116+
*/
117+
applyFilters();
118+
119+
let areFiltersClear = $(".filter_cb:checked").length === 0;
120+
$("#b_deleteFilters").prop("disabled", areFiltersClear);
121+
});
122+
});

0 commit comments

Comments
 (0)