This commit is contained in:
Christian Mantha
2026-03-02 19:10:52 -05:00
commit 2ca0b9ef7c
28907 changed files with 5233713 additions and 0 deletions

View File

@@ -0,0 +1,343 @@
from __future__ import annotations
import base64
import html
import logging
import os
import pathlib
import pickle
import random
import re
import string
from io import StringIO
from typing import Optional, Union
from packaging.version import Version
from mlflow.exceptions import INVALID_PARAMETER_VALUE, MlflowException
CARD_PICKLE_NAME = "card.pkl"
CARD_HTML_NAME = "card.html"
_PP_VARIABLE_LINK_REGEX = re.compile(r'<a\s+href="?(?P<href>#pp_var_[-0-9]+)"?\s*>')
_logger = logging.getLogger(__name__)
class CardTab:
def __init__(self, name: str, template: str) -> None:
"""
Construct a step card tab with supported HTML template.
Args:
name: A string representing the name of the tab.
template: A string representing the HTML template for the card content.
"""
import jinja2
from jinja2 import meta as jinja2_meta
self.name = name
self.template = template
j2_env = jinja2.Environment()
self._variables = jinja2_meta.find_undeclared_variables(j2_env.parse(template))
self._context = {}
def add_html(self, name: str, html_content: str) -> CardTab:
"""
Adds html to the CardTab.
Args:
name: Name of the variable in the Jinja2 template.
html_content: The html to replace the named template variable.
Returns:
The updated card instance.
"""
if name not in self._variables:
raise MlflowException(
f"{name} is not a valid template variable defined in template: '{self.template}'",
error_code=INVALID_PARAMETER_VALUE,
)
self._context[name] = html_content
return self
def add_markdown(self, name: str, markdown: str) -> CardTab:
"""
Adds markdown to the card replacing the variable name in the CardTab template.
Args:
name: Name of the variable in the CardTab Jinja2 template.
markdown: The markdown content.
Returns:
The updated card tab instance.
"""
from markdown import markdown as md_to_html
self.add_html(name, md_to_html(markdown))
return self
def add_image(
self,
name: str,
image_file_path: str,
width: Optional[int] = None,
height: Optional[int] = None,
) -> None:
if not os.path.exists(image_file_path):
self.add_html(name, "Image Unavailable")
_logger.warning(f"Unable to locate image file {image_file_path} to render {name}.")
return
with open(image_file_path, "rb") as f:
base64_str = base64.b64encode(f.read()).decode("utf-8")
image_type = pathlib.Path(image_file_path).suffix[1:]
width_style = f'width="{width}"' if width else ""
height_style = f'height="{width}"' if height else ""
img_html = (
f'<img src="data:image/{image_type};base64, {base64_str}" '
f"{width_style} {height_style} />"
)
self.add_html(name, img_html)
def add_pandas_profile(self, name: str, profile: str) -> CardTab:
"""
Add a new tab representing the provided pandas profile to the card.
Args:
name: Name of the variable in the Jinja2 template.
profile: HTML string to render profile in the step card.
Returns:
The updated card instance.
"""
try:
profile_iframe = (
"<iframe srcdoc='{src}' width='100%' height='500' frameborder='0'></iframe>"
).format(src=html.escape(profile))
except Exception as e:
profile_iframe = f"Unable to create data profile. Error found:\n{e}"
self.add_html(name, profile_iframe)
return self
def to_html(self) -> str:
"""
Returns a rendered HTML representing the content of the tab.
Returns:
a HTML string
"""
from jinja2 import BaseLoader
from jinja2.sandbox import SandboxedEnvironment
j2_env = SandboxedEnvironment(loader=BaseLoader()).from_string(self.template)
return j2_env.render({**self._context})
class BaseCard:
def __init__(self, recipe_name: str, step_name: str) -> None:
"""
BaseCard Constructor
Args:
recipe_name: A string representing name of the recipe.
step_name: A string representing the name of the step.
"""
self._recipe_name = recipe_name
self._step_name = step_name
self._template_name = "base.html"
self._string_builder = StringIO()
self._tabs = []
def add_tab(self, name, html_template) -> CardTab:
"""
Add a new tab with arbitrary content.
Args:
name: A string representing the name of the tab.
html_template: A string representing the HTML template for the card content.
"""
tab = CardTab(name, html_template)
self._tabs.append((name, tab))
return tab
def get_tab(self, name) -> Union[CardTab, None]:
"""
Returns an existing tab with the specified name. Returns None if not found.
Args:
name: A string representing the name of the tab.
Returns:
An existing tab with the specified name. If not found, returns None.
"""
for key, tab in self._tabs:
if key == name:
return tab
return None
def add_text(self, text: str) -> BaseCard:
"""
Add text to the textual representation of this card.
Args:
text: A string text.
Returns:
The updated card instance.
"""
self._string_builder.write(text)
return self
def to_html(self) -> str:
"""
This function renders the Jinja2 template based on the provided context so far.
Returns:
A HTML string.
"""
import jinja2
def get_random_id(length=6):
return "".join(
random.choice(string.ascii_lowercase + string.digits) for _ in range(length)
)
base_template_path = os.path.join(os.path.dirname(__file__), "templates")
j2_env = jinja2.Environment(loader=jinja2.FileSystemLoader(base_template_path))
tab_list = [(name, tab.to_html()) for name, tab in self._tabs]
page_id = get_random_id()
return j2_env.get_template(self._template_name).render(
{
"HEADER_TITLE": f"{self._step_name.capitalize()}@{self._recipe_name}",
"TABLINK": f"tablink-{page_id}",
"CONTENT": f"content-{page_id}",
"BUTTON_CONTAINER": f"button-container-{page_id}",
"tab_list": tab_list,
}
)
def to_text(self) -> str:
"""
Returns:
The textual representation of the card.
"""
return self._string_builder.getvalue()
def save_as_html(self, path) -> None:
if os.path.isdir(path):
path = os.path.join(path, CARD_HTML_NAME)
with open(path, "w", encoding="utf-8") as f:
f.write(self.to_html())
def save(self, path: str) -> None:
if os.path.isdir(path):
path = os.path.join(path, CARD_PICKLE_NAME)
with open(path, "wb") as out:
pickle.dump(self, out)
@staticmethod
def load(path):
if os.path.isdir(path):
path = os.path.join(path, CARD_PICKLE_NAME)
with open(path, "rb") as f:
return pickle.load(f)
@staticmethod
def render_table(table, columns=None, hide_index=True):
"""
Renders a table-like object as an HTML table.
Args:
table: Table-like object (e.g. pandas DataFrame, 2D numpy array, list of tuples).
columns: Column names to use. If `table` doesn't have column names, this argument
provides names for the columns. Otherwise, only the specified columns will be
included in the output HTML table.
hide_index: Hide index column when rendering.
"""
import pandas as pd
from pandas.io.formats.style import Styler
pandas_version = Version(pd.__version__)
if not isinstance(table, Styler):
table = pd.DataFrame(table, columns=columns)
# Escape specific characters in HTML to prevent
# javascript code injection
# Note that `pandas_df.style.to_html(escape=True) does not work
# So that we have to manually escape values in dataframe cells.
def escape_value(x):
return html.escape(str(x))
if hasattr(table, "map"):
table = table.map(escape_value)
else:
if pandas_version >= Version("2.1.0"):
table = table.map(escape_value)
else:
table = table.applymap(escape_value)
table = table.style
styler = table.set_table_attributes('style="border-collapse:collapse"').set_table_styles(
[
{
"selector": "table, th, td",
"props": [
("border", "1px solid grey"),
("text-align", "left"),
("padding", "5px"),
],
},
]
)
if hide_index:
rendered_table = (
styler.hide(axis="index").to_html()
if pandas_version >= Version("1.4.0")
else styler.hide_index().render()
)
else:
rendered_table = (
styler.to_html() if pandas_version >= Version("1.4.0") else styler.render()
)
return f'<div style="max-height: 500px; overflow: scroll;">{rendered_table}</div>'
class FailureCard(BaseCard):
"""
Step card providing information about a failed step execution, including a stacktrace.
TODO: Migrate the failure card to a tab-based card, removing this class and its associated
HTML template in the process.
"""
def __init__(
self, recipe_name: str, step_name: str, failure_traceback: str, output_directory: str
):
super().__init__(
recipe_name=recipe_name,
step_name=step_name,
)
self.add_tab("Step Status", "{{ STEP_STATUS }}").add_html(
"STEP_STATUS",
'<p><strong>Step status: <span style="color:red">Failed</span></strong></p>',
)
self.add_tab(
"Stacktrace",
(
"<div class='stacktrace-container'><p style='margin-top:0px'><code>"
"{{ STACKTRACE|e }}</code></p></div>"
),
).add_html("STACKTRACE", str(failure_traceback))
warning_output_path = os.path.join(output_directory, "warning_logs.txt")
if os.path.exists(warning_output_path):
with open(warning_output_path) as f:
self.add_tab("Warning Logs", "{{ STEP_WARNINGS }}").add_html(
"STEP_WARNINGS", f"<pre>{f.read()}</pre>"
)

View File

@@ -0,0 +1,132 @@
"""
Generates facets_overview histogram message for numeric features.
"""
from mlflow.protos.facet_feature_statistics_pb2 import Histogram
def generate_equal_height_histogram(quantiles, num_buckets: int) -> Histogram:
"""
Generates the equal height histogram from the input quantiles. The quantiles are assumed to
be ordered and corresponding to equal distant percentiles.
Args:
quantiles: The quantiles that capture the frequency distribution.
num_buckets: The number of buckets in the generated equal height histogram.
Returns:
An equal height histogram or None if inputs are invalid.
"""
if (len(quantiles) < 3) or ((len(quantiles) - 1) % num_buckets != 0):
return None
histogram = Histogram()
histogram.type = Histogram.HistogramType.QUANTILES
step = (len(quantiles) - 1) // num_buckets
for low_index in range(0, len(quantiles) - step, step):
high_index = low_index + step
histogram.buckets.append(
Histogram.Bucket(low_value=quantiles[low_index], high_value=quantiles[high_index])
)
return histogram
def generate_equal_width_histogram(quantiles, num_buckets: int, total_freq: float) -> Histogram:
"""
Generates the equal width histogram from the input quantiles and total frequency. The
quantiles are assumed to be ordered and corresponding to equal distant percentiles.
Args:
quantiles: The quantiles that capture the frequency distribution.
num_buckets: The number of buckets in the generated histogram.
total_freq: The total frequency (=count of rows).
Returns:
Equal width histogram or None if inputs are invalid.
"""
if len(quantiles) < 2 or num_buckets <= 0 or total_freq <= 0:
return None
min_val = quantiles[0]
max_val = quantiles[-1]
# If all values are the same, the width of all buckets will be 1 as fixed,
# except the bucket that contains the real value. The width of that will be 0.
histogram = Histogram()
histogram.type = Histogram.HistogramType.STANDARD
if min_val == max_val:
half_buckets = num_buckets // 2
bucket_left = min_val - half_buckets
for i in range(num_buckets):
if i == half_buckets:
histogram.buckets.append(
Histogram.Bucket(
low_value=bucket_left, high_value=bucket_left, sample_count=total_freq
)
)
else:
histogram.buckets.append(
Histogram.Bucket(
low_value=bucket_left, high_value=bucket_left + 1, sample_count=0
)
)
bucket_left += 1
else:
bucket_width = (max_val - min_val) / num_buckets
for i in range(num_buckets):
bucket_left = min_val + i * bucket_width
bucket_right = bucket_left + bucket_width
histogram.buckets.append(
_generate_equal_width_histogram_internal(
bucket_left=bucket_left,
bucket_right=bucket_right,
quantiles=quantiles,
total_freq=total_freq,
)
)
return histogram
def _generate_equal_width_histogram_internal(
bucket_left: float,
bucket_right: float,
quantiles,
total_freq: float,
) -> Histogram.Bucket:
"""
Generates a histogram bucket given the bucket range, the quantiles and the total frequency.
Args:
bucket_left: Bucket left boundary.
bucket_right: Bucket right boundary.
quantiles: The quantiles that capture the frequency distribution.
total_freq: The total frequency (=count of rows).
Returns:
The histogram bucket corresponding to the inputs.
"""
max_val = quantiles[-1]
bucket_freq = 0.0
quantile_freq = total_freq / (len(quantiles) - 1)
for i in range(len(quantiles) - 1):
quantile_low = quantiles[i]
quantile_high = quantiles[i + 1]
overlap_low = max(quantile_low, bucket_left)
overlap_high = min(quantile_high, bucket_right)
overlap_length = overlap_high - overlap_low
quantile_contribution_ratio = 0.0
if quantile_low == quantile_high:
if (bucket_left <= quantile_low < bucket_right) or (
quantile_low == bucket_right == max_val
):
quantile_contribution_ratio = 1.0
elif overlap_length > 0:
quantile_contribution_ratio = overlap_length / (quantile_high - quantile_low)
bucket_freq += quantile_freq * quantile_contribution_ratio
return Histogram.Bucket(
low_value=bucket_left, high_value=bucket_right, sample_count=bucket_freq
)

View File

@@ -0,0 +1,325 @@
"""
Renders the statistics of logged data in a HTML format.
"""
import base64
import sys
from typing import Iterable, Union
import numpy as np
import pandas as pd
from packaging.version import Version
from mlflow.exceptions import MlflowException
from mlflow.protos import facet_feature_statistics_pb2
from mlflow.recipes.cards import histogram_generator
# Number of categorical strings values to be rendered as part of the histogram
HISTOGRAM_CATEGORICAL_LEVELS_COUNT = 100
def get_facet_type_from_numpy_type(dtype):
"""Converts a Numpy dtype to the FeatureNameStatistics.Type proto enum."""
fs_proto = facet_feature_statistics_pb2.FeatureNameStatistics
if dtype.char in np.typecodes["Complex"]:
raise MlflowException(
"Found type complex, but expected one of: int, long, float, string, bool"
)
elif dtype.char in np.typecodes["AllFloat"]:
return fs_proto.FLOAT
elif (
dtype.char in np.typecodes["AllInteger"]
or np.issubdtype(dtype, np.datetime64)
or np.issubdtype(dtype, np.timedelta64)
):
return fs_proto.INT
else:
return fs_proto.STRING
def datetime_and_timedelta_converter(dtype):
"""
Converts a Numpy dtype to a converter method if applicable.
The converter method takes in a numpy array of objects of the provided
dtype and returns a numpy array of the numbers backing that object for
statistical analysis. Returns None if no converter is necessary.
Args:
dtype: The numpy dtype to make a converter for.
Returns:
The converter method or None.
"""
if np.issubdtype(dtype, np.datetime64):
def datetime_converter(dt_list):
return np.array([pd.Timestamp(dt).value for dt in dt_list])
return datetime_converter
elif np.issubdtype(dtype, np.timedelta64):
def timedelta_converter(td_list):
return np.array([pd.Timedelta(td).value for td in td_list])
return timedelta_converter
else:
return None
def compute_common_stats(column) -> facet_feature_statistics_pb2.CommonStatistics:
"""
Computes common statistics for a given column in the DataFrame.
Args:
column: A column from a DataFrame.
Returns:
A CommonStatistics proto.
"""
common_stats = facet_feature_statistics_pb2.CommonStatistics()
common_stats.num_missing = column.isnull().sum()
common_stats.num_non_missing = len(column) - common_stats.num_missing
# TODO: Add support to multi dimensional columns similar to
# https://github.com/PAIR-code/facets/blob/4742b8b93c2dacf22fc8ace2cee42dd06382c48e/facets_overview/facets_overview/base_generic_feature_statistics_generator.py#L106-L117
common_stats.min_num_values = 1
common_stats.max_num_values = 1
common_stats.avg_num_values = 1.0
return common_stats
def convert_to_dataset_feature_statistics(
df: pd.DataFrame,
) -> facet_feature_statistics_pb2.DatasetFeatureStatistics:
"""
Converts the data statistics from DataFrame format to DatasetFeatureStatistics proto.
Args:
df: The DataFrame for which feature statistics need to be computed.
Returns:
A DatasetFeatureStatistics proto.
"""
fs_proto = facet_feature_statistics_pb2.FeatureNameStatistics
feature_stats = facet_feature_statistics_pb2.DatasetFeatureStatistics()
data_type_custom_stat = facet_feature_statistics_pb2.CustomStatistic()
kwargs = {} if Version(pd.__version__) >= Version("2.0.0rc0") else {"datetime_is_numeric": True}
pandas_describe = df.describe(include="all", **kwargs)
feature_stats.num_examples = len(df)
quantiles_to_get = [x * 10 / 100 for x in range(10 + 1)]
try:
quantiles = df.select_dtypes(include="number").quantile(quantiles_to_get)
except Exception:
raise MlflowException("Error in generating quantiles")
for key in df:
pandas_describe_key = pandas_describe[key]
current_column_value = df[key]
data_type = current_column_value.dtype
data_type_custom_stat.name = "data type"
data_type_custom_stat.str = str(data_type)
feat = feature_stats.features.add(
type=get_facet_type_from_numpy_type(data_type),
name=key.encode("utf-8"),
custom_stats=[data_type_custom_stat],
)
if feat.type in (fs_proto.INT, fs_proto.FLOAT):
feat_stats = feat.num_stats
converter = datetime_and_timedelta_converter(current_column_value.dtype)
if converter:
date_time_converted = converter(current_column_value)
current_column_value = pd.DataFrame(date_time_converted)[0]
kwargs = (
{}
if Version(pd.__version__) >= Version("2.0.0rc0")
else {"datetime_is_numeric": True}
)
pandas_describe_key = current_column_value.describe(include="all", **kwargs)
quantiles[key] = current_column_value.quantile(quantiles_to_get)
default_value = 0
feat_stats.std_dev = pandas_describe_key.get("std", default_value)
feat_stats.mean = pandas_describe_key.get("mean", default_value)
feat_stats.min = pandas_describe_key.get("min", default_value)
feat_stats.max = pandas_describe_key.get("max", default_value)
feat_stats.median = current_column_value.median()
feat_stats.num_zeros = (current_column_value == 0).sum()
feat_stats.common_stats.CopyFrom(compute_common_stats(current_column_value))
if key in quantiles:
equal_width_hist = histogram_generator.generate_equal_width_histogram(
quantiles=quantiles[key].to_numpy(),
num_buckets=10,
total_freq=feat_stats.common_stats.num_non_missing,
)
if equal_width_hist:
feat_stats.histograms.append(equal_width_hist)
equal_height_hist = histogram_generator.generate_equal_height_histogram(
quantiles=quantiles[key].to_numpy(), num_buckets=10
)
if equal_height_hist:
feat_stats.histograms.append(equal_height_hist)
elif feat.type == fs_proto.STRING:
is_current_column_boolean_type = False
if current_column_value.dtype == bool:
current_column_value = current_column_value.replace({True: "True", False: "False"})
is_current_column_boolean_type = True
feat_stats = feat.string_stats
strs = current_column_value.dropna()
feat_stats.avg_length = (
np.mean(np.vectorize(len)(strs))
if not is_current_column_boolean_type and not current_column_value.isnull().all()
else 0
)
vals, counts = np.unique(strs, return_counts=True)
feat_stats.unique = pandas_describe_key.get("unique", len(vals))
sorted_vals = sorted(zip(counts, vals), reverse=True)
sorted_vals = sorted_vals[:HISTOGRAM_CATEGORICAL_LEVELS_COUNT]
for val_index, val in enumerate(sorted_vals):
try:
if sys.version_info.major < 3 or isinstance(val[1], (bytes, bytearray)):
printable_val = val[1].decode("UTF-8", "strict")
else:
printable_val = val[1]
except (UnicodeDecodeError, UnicodeEncodeError):
printable_val = "__BYTES_VALUE__"
bucket = feat_stats.rank_histogram.buckets.add(
low_rank=val_index,
high_rank=val_index,
sample_count=val[0].item(),
label=printable_val,
)
if val_index < 2:
feat_stats.top_values.add(value=bucket.label, frequency=bucket.sample_count)
feat_stats.common_stats.CopyFrom(compute_common_stats(current_column_value))
return feature_stats
def convert_to_proto(df: pd.DataFrame) -> facet_feature_statistics_pb2.DatasetFeatureStatisticsList:
"""
Converts the data from DataFrame format to DatasetFeatureStatisticsList proto.
Args:
df: The DataFrame for which feature statistics need to be computed.
Returns:
A DatasetFeatureStatisticsList proto.
"""
feature_stats = convert_to_dataset_feature_statistics(df)
feature_stats_list = facet_feature_statistics_pb2.DatasetFeatureStatisticsList()
feature_stats_list.datasets.append(feature_stats)
return feature_stats_list
def convert_to_comparison_proto(
dfs: Iterable[tuple[str, pd.DataFrame]],
) -> facet_feature_statistics_pb2.DatasetFeatureStatisticsList:
"""
Converts a collection of named stats DataFrames to a single DatasetFeatureStatisticsList proto.
Args:
dfs: The named "glimpses" that contain the DataFrame. Each "glimpse"
DataFrame has the same properties as the input to `convert_to_proto()`.
Returns:
A DatasetFeatureStatisticsList proto which contains a translation
of the glimpses with the given names.
"""
feature_stats_list = facet_feature_statistics_pb2.DatasetFeatureStatisticsList()
for name, df in dfs:
if not df.empty:
proto = convert_to_dataset_feature_statistics(df)
proto.name = name
feature_stats_list.datasets.append(proto)
return feature_stats_list
def get_facets_polyfills() -> str:
"""
A JS polyfill/monkey-patching function that fixes issue where objectURL passed as a
"base" argument to the URL constructor ends up in a "invalid URL" exception.
Polymer is using parent's URL in its internal asset URL resolution system, while MLFLow
artifact rendering engine uses object URLs to display iframed artifacts code. This ends up
in object URL being used in `new URL()` constructor which needs to be patched.
Original function code:
(function patchURLConstructor() {
const _originalURLConstructor = window.URL;
window.URL = function (url, base) {
if (typeof base === "string" && base.startsWith("blob:")) {
return new URL(base);
}
return new _originalURLConstructor(url, base);
};
})();
"""
return """
!function() {
let t = window.URL;
window.URL = function(n, e) {
if (typeof e === "string" && e.startsWith("blob:")) {
return new URL(e);
} else {
return new t(n, e);
}
}
}();
"""
def construct_facets_html(
proto: facet_feature_statistics_pb2.DatasetFeatureStatisticsList, compare: bool = False
) -> str:
"""
Constructs the facets HTML to visualize the serialized FeatureStatisticsList proto.
Args:
proto: A DatasetFeatureStatisticsList proto which contains the statistics for a DataFrame.
compare: If True, then the returned visualization switches on the comparison
mode for several stats.
Returns:
The HTML for Facets visualization.
"""
# facets_html_bundle = _get_facets_html_bundle()
protostr = base64.b64encode(proto.SerializeToString()).decode("utf-8")
polyfills_code = get_facets_polyfills()
return f"""
<div style="background-color: white">
<script>{polyfills_code}</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.3.3/webcomponents-lite.js"></script>
<link rel="import" href="https://raw.githubusercontent.com/PAIR-code/facets/1.0.0/facets-dist/facets-jupyter.html" >
<facets-overview id="facets" proto-input="{protostr}" compare-mode="{compare}"></facets-overview>
</div>
""" # noqa: E501
def get_html(inputs: Union[pd.DataFrame, Iterable[tuple[str, pd.DataFrame]]]) -> str:
"""
Rendering the data statistics in a HTML format.
Args:
inputs: Either a single "glimpse" DataFrame that contains the statistics, or a
collection of (name, DataFrame) pairs where each pair names a separate "glimpse"
and they are all visualized in comparison mode.
Returns:
None
"""
if isinstance(inputs, pd.DataFrame):
if not inputs.empty:
proto = convert_to_proto(inputs)
compare = False
else:
proto = convert_to_comparison_proto(inputs)
compare = True
return construct_facets_html(proto, compare=compare)

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{HEADER_TITLE}}</title>
<style>
h1.title {
color: #4287f5;
}
h2.step-title {
color : #4287f5;
}
.tab {
overflow: hidden;
border: 1px solid #ccc;
background-color: #f1f1f1;
}
.tab button {
background-color: inherit;
float: left;
border: none;
outline: none;
cursor: pointer;
padding: 14px 16px;
transition: 0.3s;
}
.tab button:hover {
background-color: #ddd;
}
.tab button.active {
background-color: #ccc;
}
.tabcontent {
display: block;
padding: 6px 12px;
border: 1px solid #ccc;
border-top: none;
}
.content-hide {
display: none;
}
.content-active {
display: block;
}
h3.section-title {
color: #57a8de;
}
.dataset-container {
width: max;
overflow-x: auto;
white-space: nowrap;
}
.stacktrace-container {
max-height: 300px;
overflow: auto;
display: flex;
flex-direction: column-reverse;
white-space: pre-wrap;
}
</style>
</head>
<body>
<div class="tab">
<div class="{{BUTTON_CONTAINER}}">
{% for tab in tab_list %}
<button id={{loop.index - 1}} class="{{TABLINK}}">
{{tab.0}}
</button>
{% endfor %}
</div>
</div>
<div class="tabcontent">
{% for tab in tab_list %}
<div class="{{CONTENT}} content-hide">{{ tab.1 }}</div>
{% endfor %}
</div>
<script>
function onclickTab(event) {
const tablinks = document.getElementsByClassName("{{TABLINK}}");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace("active", "");
}
event.target.classList.add("active");
const tabId = event.target.id;
const tabContents = document.getElementsByClassName("{{CONTENT}}");
for (i = 0; i < tabContents.length; i++) {
tabContents[i].className = tabContents[i].className.replace("content-active", "content-hide");
}
tabContents[tabId].classList.add("content-active");
}
(function() {
const buttonsContainer = document.getElementsByClassName("{{BUTTON_CONTAINER}}")[0]
buttonsContainer.addEventListener("click", onclickTab);
const tablinks = document.getElementsByClassName("{{TABLINK}}");
tablinks[0].classList.add("active");
const tabContents = document.getElementsByClassName("{{CONTENT}}");
tabContents[0].classList.add("content-active");
})()
</script>
</body>
</html>