from math import ceil from typing import Any, Dict, List, Optional, Sequence, Tuple, Union import pandas as pd import plotly.express as px import plotly.graph_objects as go from dash import Dash, dcc, html from dash.dependencies import Input, Output from flask import Flask from ....constants import DASHBOARD_PATH, ONLINE_TAG_NAME from ....context import get_context from ....helper import freeze, snake_case_to_text, text_to_hex_color from ....utilities import unique from ....views import SortBy, Trace from .get_description import get_description from .get_filter_from_datatable import get_filter_from_datatable from .get_footer import get_footer from .get_traces_table import get_traces_table def create_dash_app(function_name: str, version: str, function_docs: str) -> Flask: accent_color = text_to_hex_color(function_name) app = Dash( function_name, requests_pathname_prefix=DASHBOARD_PATH + "/", server=Flask(__name__), title=snake_case_to_text(function_name), update_title=None, external_stylesheets=[ "/assets/index.css", ], ) app.layout = html.Main( [ html.Div( html.P("PRODUCTION" if get_context().is_production else "DEVELOPMENT"), className="environment", style={"background": accent_color}, ), html.Header( [ get_description( function_name=function_name, version=version, function_docs=function_docs, accent_color=accent_color, ), execution_time_histogram_container := html.Div(), ], ), configuration_container := html.Div( className="configuration-container", ), traces_table_container := html.Div( [ html.Header( [ html.H2("Latest traces"), html.P( "Recent traces and aggregated metrics are presented below. Try filtering the table." ), html.A( "Filtering syntax.", href="https://dash.plotly.com/datatable/filtering", target="_blank", ), ] ), table := get_traces_table(), ], className="traces-table-container", ), parallel_coordinates := dcc.Graph( className="parallel-coordinates", config={"displaylogo": False} ), html.Div(className="space-filler"), get_footer(), interval := dcc.Interval( interval=4 * 1000, # in milliseconds ), ] ) @app.callback( Output(configuration_container, "children"), Input(interval, "n_intervals"), ) def update_configuration( n_intervals: int, ) -> List[html.Div]: config = get_context().to_flat_dict() return [ html.Div( [ html.H4(snake_case_to_text(key)), html.P(str(value)), ], className="configuration-item", style={"border-left": f"2px solid {accent_color}"}, ) for key, value in config.items() ] @app.callback( Output(table, "data"), Output(table, "page_count"), Output(table, "columns"), Output(traces_table_container, "style"), Output(execution_time_histogram_container, "children"), Output(parallel_coordinates, "figure"), Output(parallel_coordinates, "style"), Input(table, "page_current"), Input(table, "page_size"), Input(table, "sort_by"), Input(table, "filter_query"), Input(interval, "n_intervals"), ) def update_page( page_current: int, page_size: int, sort_by: List[Dict[str, Union[str, int]]], filter_query: str, n_intervals: int, ) -> Tuple[ List[Dict[str, Any]], int, List[Dict[str, Sequence[str]]], Dict[str, Any], Any, go.Figure, Dict[str, Any], ]: conjunctive_filters = ( [get_filter_from_datatable(f) for f in filter_query.split(" && ")] if filter_query else [] ) non_null_conjunctive_filters = [f for f in conjunctive_filters if f is not None] elements, count = get_context().tracing_database.query( skip=page_current * page_size, take=page_size, conjunctive_filters=non_null_conjunctive_filters, conjunctive_tags=[ONLINE_TAG_NAME], sort_by=[SortBy.parse_obj(s) for s in sort_by], ) columns, style = update_layout(elements[0] if elements else None) execution_time_histogram, parallel_coords_fig, style = update_charts( elements=elements, function_name=function_name, accent_color=accent_color ) return ( [ {k: str(v) for k, v in e.to_flat_dict(include_original=False).items()} for e in elements ], max(1, ceil(count / page_size)), columns, style, execution_time_histogram, parallel_coords_fig, style, ) return app.server def update_layout( first_element: Optional[Trace], ) -> Tuple[List[Dict[str, Sequence[str]]], Dict[str, Any]]: if first_element: keys = list(first_element.to_flat_dict(include_original=False).keys()) header_height = max(len(i.split(":")) for i in keys) columns = [ { "name": [""] * (header_height - len(k.split(":"))) + k.replace("_flat", "").split(":"), "id": k, } for k in keys ] else: columns = [] return ( columns, {"display": "none" if first_element is None else "block"}, ) def update_charts( elements: List[Trace], function_name: str, accent_color: str ) -> Tuple[Any, go.Figure, Dict[str, Any]]: if not elements: return ( html.Span( f"No traces yet: call your function ({function_name}) to create one.", className="placeholder", ), go.Figure(), {"display": "none"}, ) flat_elements = [e.to_flat_dict(include_original=False) for e in elements] execution_time_histogram = dcc.Graph(config={"displaylogo": False}) df = pd.DataFrame(flat_elements) fig = px.histogram( df, x="original_execution_time_ms", labels={"original_execution_time_ms": "Execution time (ms)"}, nbins=20, height=400, log_y=True, color_discrete_sequence=[accent_color], ) fig.update_layout( margin=dict(l=0, r=0, b=0, t=0, pad=0), ) execution_time_histogram.figure = fig parallel_coords_fig = go.Figure( go.Parcoords( dimensions=[ get_dimension_descriptor(df, c) for c in df.columns if c not in {"trace_id", "created", "output", "exception", "feedback"} and "_flat" not in c ], line_color=accent_color, ) ) return execution_time_histogram, parallel_coords_fig, {} def get_dimension_descriptor(df: pd.DataFrame, column: str) -> Dict[str, Any]: dimension: Dict[str, Any] = { "label": snake_case_to_text(column), } values = df[column] try: dimension["values"] = [float(v) for v in values] except (TypeError, ValueError): MAX_LENGTH = 40 unique_values = unique(values, key=freeze) value_mapping = {str(v)[:MAX_LENGTH]: i for i, v in enumerate(unique_values)} dimension["values"] = [value_mapping[str(v)[:MAX_LENGTH]] for v in values] dimension["tickvals"] = list(value_mapping.values()) dimension["ticktext"] = [k[:MAX_LENGTH] for k in value_mapping.keys()] return dimension