dashboard.py
1 import base64 2 import dataclasses 3 import html 4 import json 5 import os 6 import shutil 7 from enum import Enum 8 from typing import Dict 9 from typing import List 10 from typing import Optional 11 12 import evidently 13 from evidently.legacy.model.dashboard import DashboardInfo 14 from evidently.legacy.utils import NumpyEncoder 15 16 STATIC_PATH = os.path.join(evidently.__path__[0], "nbextension", "static") 17 18 19 class SaveMode(Enum): 20 SINGLE_FILE = "singlefile" 21 FOLDER = "folder" 22 SYMLINK_FOLDER = "symlink_folder" 23 24 25 SaveModeMap = {v.value: v for v in SaveMode} 26 27 28 @dataclasses.dataclass() 29 class TemplateParams: 30 dashboard_id: str 31 dashboard_info: DashboardInfo 32 additional_graphs: Dict 33 embed_font: bool = True 34 embed_lib: bool = True 35 embed_data: bool = True 36 font_file: Optional[str] = None 37 include_js_files: List[str] = dataclasses.field(default_factory=list) 38 39 40 def save_lib_files(filename: str, mode: SaveMode): 41 if mode == SaveMode.SINGLE_FILE: 42 return None, None 43 parent_dir = os.path.dirname(filename) 44 if not os.path.exists(os.path.join(parent_dir, "js")): 45 os.makedirs(os.path.join(parent_dir, "js"), exist_ok=True) 46 font_file = os.path.join(parent_dir, "js", "material-ui-icons.woff2") 47 lib_file = os.path.join(parent_dir, "js", f"evidently.{evidently.__version__}.js") 48 49 if mode == SaveMode.SYMLINK_FOLDER: 50 if os.path.exists(font_file): 51 os.remove(font_file) 52 os.symlink(os.path.join(STATIC_PATH, "material-ui-icons.woff2"), font_file) 53 if os.path.exists(lib_file): 54 os.remove(lib_file) 55 os.symlink(os.path.join(STATIC_PATH, "index.js"), lib_file) 56 else: 57 shutil.copy(os.path.join(STATIC_PATH, "material-ui-icons.woff2"), font_file) 58 shutil.copy(os.path.join(STATIC_PATH, "index.js"), lib_file) 59 return font_file, lib_file 60 61 62 def save_data_file( 63 filename: str, 64 mode: SaveMode, 65 dashboard_id, 66 dashboard_info: DashboardInfo, 67 additional_graphs: Dict, 68 ): 69 if mode == SaveMode.SINGLE_FILE: 70 return None 71 parent_dir = os.path.dirname(filename) 72 if parent_dir and not os.path.exists(parent_dir): 73 os.makedirs(parent_dir, exist_ok=True) 74 base_name = os.path.basename(filename) 75 data_file = os.path.join(parent_dir, "js", f"{base_name}.data.js") 76 with open(data_file, "w", encoding="utf-8") as out_file: 77 out_file.write( 78 f""" 79 var {dashboard_id} = {dashboard_info_to_json(dashboard_info)}; 80 var additional_graphs_{dashboard_id} = {json.dumps(additional_graphs, cls=NumpyEncoder)};""" 81 ) 82 return data_file 83 84 85 def dashboard_info_to_json(dashboard_info: DashboardInfo): 86 asdict_result = dashboard_info.dict() 87 for widget in asdict_result["widgets"]: 88 widget.pop("additionalGraphs", None) 89 return json.dumps(asdict_result, cls=NumpyEncoder) 90 91 92 def file_html_template(params: TemplateParams): 93 lib_block = f"""<script>{__load_js()}</script>""" if params.embed_lib else "<!-- no embedded lib -->" 94 95 data_block = ( 96 f"""<script> 97 var {params.dashboard_id} = {dashboard_info_to_json(params.dashboard_info)}; 98 var additional_graphs_{params.dashboard_id} = {json.dumps(params.additional_graphs, cls=NumpyEncoder)}; 99 </script>""" 100 if params.embed_data 101 else "<!-- no embedded data -->" 102 ) 103 104 js_files_block = "\n".join([f'<script src="{file}"></script>' for file in params.include_js_files]) 105 106 style = f""" 107 <style> 108 /* fallback */ 109 @font-face {{ 110 font-family: 'Material Icons'; 111 font-style: normal; 112 font-weight: 400; 113 src: {f"url(data:font/ttf;base64,{__load_font()}) format('woff2');" if params.embed_font else 114 f"url({params.font_file});"} 115 }} 116 117 .center-align {{ 118 text-align: center; 119 }} 120 121 .material-icons {{ 122 font-family: 'Material Icons'; 123 font-weight: normal; 124 font-style: normal; 125 font-size: 24px; 126 line-height: 1; 127 letter-spacing: normal; 128 text-transform: none; 129 display: inline-block; 130 white-space: nowrap; 131 word-wrap: normal; 132 direction: ltr; 133 text-rendering: optimizeLegibility; 134 -webkit-font-smoothing: antialiased; 135 }} 136 </style> 137 """ 138 139 return f""" 140 <!DOCTYPE html> 141 <html lang="en"> 142 <head> 143 <meta charset="UTF-8"> 144 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 145 {style} 146 {data_block} 147 </head> 148 <body> 149 <div id="root_{params.dashboard_id}"> 150 </div> 151 <script>var global = globalThis</script> 152 {lib_block} 153 {js_files_block} 154 <script> 155 window.drawDashboard({params.dashboard_id}, 156 new Map(Object.entries(additional_graphs_{params.dashboard_id})), 157 "root_{params.dashboard_id}" 158 ); 159 </script> 160 161 </body> 162 </html> 163 """ 164 165 166 def inline_iframe_html_template(params: TemplateParams): 167 resize_script = """ 168 <script type="application/javascript"> 169 ;(function () { 170 const main = () => { 171 window.evidentlyResizeTargetAndIframePair ??= [] 172 window.evidentlyResizeObserver ??= createObserver() 173 174 document.querySelectorAll('iframe.evidently-ui-iframe').forEach((iframe) => { 175 iframe.onload = () => { 176 const targetToObserveResize = iframe.contentWindow.document.body 177 178 window.evidentlyResizeTargetAndIframePair.push([targetToObserveResize, iframe]) 179 window.evidentlyResizeObserver.observe(targetToObserveResize) 180 } 181 }) 182 } 183 184 const createObserver = () => 185 new ResizeObserver((entities) => { 186 for (const entity of entities) { 187 const resizeTargetAndIframePair = window.evidentlyResizeTargetAndIframePair.find( 188 ([target]) => entity.target.isSameNode(target) 189 ) 190 191 if (!resizeTargetAndIframePair) { 192 break 193 } 194 195 const [, iframe] = resizeTargetAndIframePair 196 197 const iframeHeight = iframe.contentWindow.document.body.scrollHeight 198 const newHeight = iframeHeight + 2.5 199 200 if (iframeHeight === 0 || Number(iframe.height) === newHeight) { 201 break 202 } 203 204 // set new height 205 iframe.height = newHeight 206 } 207 }) 208 209 main() 210 })() 211 </script> 212 """ 213 214 html_doc = file_html_template(params) 215 216 return f""" 217 {resize_script} 218 <iframe class='evidently-ui-iframe' width="100%" frameborder="0" srcdoc="{html.escape(html_doc)}"> 219 """ 220 221 222 def __load_js(): 223 with open(os.path.join(STATIC_PATH, "index.js"), encoding="utf-8") as f: 224 return f.read() 225 226 227 def __load_font(): 228 with open(os.path.join(STATIC_PATH, "material-ui-icons.woff2"), "rb") as f: 229 return base64.b64encode(f.read()).decode()