/ src / evidently / legacy / utils / dashboard.py
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()