Skip to content

tdfs4ds.serve.dashboard_report

tdfs4ds.serve.dashboard_report

tdfs4ds.serve.dashboard_report

Render a compelling, self-contained interactive HTML report from a plain data dict describing the feature store (inventory, insight facts, technical storage-table sizes, orphan features, process run-history, freshness alerts, lineage dependency counts).

Same philosophy as :mod:tdfs4ds.benchmark.report: the HTML is fully offline — inline SVG charts + scoped CSS, no JavaScript — so it renders identically in any browser, prints cleanly, embeds directly in the admin panel, and exports to a single .html file. Interactivity is via native <details> expand/collapse.

The builder consumes only JSON-serialisable data (records / numbers), never Plotly figures, so the same document shows on-screen and downloads byte-identical.

Public: :func:build_dashboard_report, :func:write_dashboard_report.

_NAVY module-attribute

_NAVY = '#00233C'

_ORANGE module-attribute

_ORANGE = '#FF5F00'

_GREEN module-attribute

_GREEN = '#27ae60'

_RED module-attribute

_RED = '#e74c3c'

_GREY module-attribute

_GREY = '#95a5a6'

_TREEMAP_JS module-attribute

_TREEMAP_JS = '\n(function(){\n  var wrap = document.currentScript.closest(\'.tm-wrap\');\n  if(!wrap || wrap._tmInit) return; wrap._tmInit = true;\n  var model = JSON.parse(wrap.querySelector(\'.tm-data\').textContent || \'{}\');\n  var svg = wrap.querySelector(\'svg.treemap\');\n  var crumb = wrap.querySelector(\'.tm-crumb\');\n  var upBtn = wrap.querySelector(\'.tm-up\');\n  var detail = wrap.querySelector(\'.tm-detail\');\n  var stack = []; // breadcrumb of {label, nodes, kind}\n  function shade(r){ r=Math.max(0,Math.min(1, r||0));\n    var a=[255,210,176], b=[194,65,12];\n    return \'rgb(\'+Math.round(a[0]+(b[0]-a[0])*r)+\',\'+Math.round(a[1]+(b[1]-a[1])*r)+\',\'+Math.round(a[2]+(b[2]-a[2])*r)+\')\'; }\n  function esc(s){ return String(s==null?\'\':s).replace(/&/g,\'&amp;\').replace(/</g,\'&lt;\').replace(/>/g,\'&gt;\').replace(/"/g,\'&quot;\'); }\n  function fmt(n){ try{return Number(n).toLocaleString();}catch(e){return n;} }\n  function trunc(s,n){ s=String(s); return s.length<=n?s:s.slice(0,n-1)+\'…\'; }\n  function level(){\n    if(stack.length===0) return {kind:\'table\', nodes:model.tables||[], crumb:\'All feature tables\'};\n    return stack[stack.length-1];\n  }\n  function render(){\n    var L = level(); var nodes=(L.nodes||[]).filter(function(n){return (n.value||0)>0;});\n    nodes.sort(function(a,b){return (b.value||0)-(a.value||0);});\n    crumb.textContent = L.crumb || \'\';\n    upBtn.disabled = stack.length===0;\n    var W=960, H=430, total=nodes.reduce(function(s,n){return s+(n.value||0);},0)||1;\n    var nrows=Math.max(1, Math.round(Math.sqrt(nodes.length||1)));\n    var per=Math.ceil((nodes.length||1)/nrows);\n    var rowsArr=[]; for(var i=0;i<nodes.length;i+=per) rowsArr.push(nodes.slice(i,i+per));\n    var out=\'\'; var y=0;\n    rowsArr.forEach(function(row){\n      var rv=row.reduce(function(s,n){return s+(n.value||0);},0)||1;\n      var rh=(rv/total)*H, x=0;\n      row.forEach(function(n){\n        var w=(n.value/rv)*W; var fits=w>60&&rh>24;\n        var canDrill = (L.kind===\'table\' && (n.processes||[]).length) ||\n                       (L.kind===\'process\' && (n.features||[]).length);\n        var fill = n.orphan ? \'#fdecea\' : shade(n.ratio);\n        var stroke = n.orphan ? \'#e74c3c\' : \'#fff\';\n        var hover = hoverText(L.kind, n);\n        out += \'<g class="tm-tile\'+(canDrill?\' tm-can\':\'\')+\'" data-i="\'+nodes.indexOf(n)+\'">\'\n          + \'<rect x="\'+x.toFixed(1)+\'" y="\'+y.toFixed(1)+\'" width="\'+Math.max(1,w).toFixed(1)\n          + \'" height="\'+Math.max(1,rh).toFixed(1)+\'" rx="2" fill="\'+fill+\'" stroke="\'+stroke\n          + \'" stroke-width="\'+(n.orphan?2:1.5)+\'"><title>\'+esc(hover)+\'</title></rect>\';\n        if(fits){\n          out += \'<text x="\'+(x+7)+\'" y="\'+(y+18)+\'" class="tm-label">\'\n              + (n.orphan?\'⚠ \':\'\') + esc(trunc(n.label, Math.floor(w/7.5)))+\'</text>\';\n          if(rh>36) out += \'<text x="\'+(x+7)+\'" y="\'+(y+34)+\'" class="tm-sub">\'+fmt(n.value)+(L.kind===\'table\'?\' rows\':\'\')+\'</text>\';\n        }\n        out += \'</g>\';\n        x+=w;\n      });\n      y+=rh;\n    });\n    svg.innerHTML = out;\n    Array.prototype.forEach.call(svg.querySelectorAll(\'.tm-tile\'), function(g){\n      g.addEventListener(\'click\', function(){\n        var n = nodes[+g.getAttribute(\'data-i\')]; drill(L.kind, n);\n      });\n    });\n    renderDetail(L.kind, nodes);\n  }\n  function hoverText(kind, n){\n    if(kind===\'table\') return n.label+\'\\n\'+n.table+(n.process_id?(\'\\nprocess \'+n.process_id):\'\')+\'\\n\'+fmt(n.value)+\' stored rows\';\n    if(kind===\'process\') return n.label+(n.process_id?(\'\\nprocess \'+n.process_id):\'\')+\'\\n\'+(n.features?n.features.length:0)+\' features · ~\'+fmt(n.value)+\' rows\';\n    return n.label+\'\\n\'+(n.type||\'\')+(n.entity?(\' · \'+n.entity):\'\')+(n.orphan?\'\\n⚠ orphan (not in catalog)\':\'\')+\'\\n~\'+fmt(n.value)+\' rows\';\n  }\n  function drill(kind, n){\n    if(kind===\'table\' && (n.processes||[]).length){\n      stack.push({kind:\'process\', nodes:n.processes, crumb:\'Table \'+n.label+\' › processes\'});\n      render();\n    } else if(kind===\'process\' && (n.features||[]).length){\n      stack.push({kind:\'feature\', nodes:n.features, crumb:(crumb.textContent)+\' › \'+n.label+\' › features\'});\n      render();\n    }\n  }\n  function renderDetail(kind, nodes){\n    var html=\'\';\n    if(kind===\'table\'){\n      html=\'<table class="data"><thead><tr><th>Process view</th><th>Process id</th><th>Features</th><th>Rows</th></tr></thead><tbody>\';\n      nodes.forEach(function(n){ html+=\'<tr><td class="name">\'+esc(n.label)+\'</td><td class="mono">\'+esc(n.process_id||\'—\')+\'</td><td>\'+(n.processes?n.processes.reduce(function(s,p){return s+(p.features?p.features.length:0);},0):0)+\'</td><td>\'+fmt(n.value)+\'</td></tr>\'; });\n      html+=\'</tbody></table>\';\n    } else if(kind===\'process\'){\n      html=\'<table class="data"><thead><tr><th>Process view</th><th>Process id</th><th>#Features</th><th>Rows</th></tr></thead><tbody>\';\n      nodes.forEach(function(n){ html+=\'<tr><td class="name">\'+esc(n.label)+\'</td><td class="mono">\'+esc(n.process_id||\'—\')+\'</td><td>\'+(n.features?n.features.length:0)+\'</td><td>\'+fmt(n.value)+\'</td></tr>\'; });\n      html+=\'</tbody></table>\';\n    } else {\n      html=\'<table class="data"><thead><tr><th>Feature</th><th>Type</th><th>Entity</th><th>Orphan</th><th>Rows</th></tr></thead><tbody>\';\n      nodes.forEach(function(n){ html+=\'<tr><td class="name">\'+esc(n.label)+\'</td><td>\'+esc(n.type||\'—\')+\'</td><td>\'+esc(n.entity||\'—\')+\'</td><td>\'+(n.orphan?\'⚠ yes\':\'no\')+\'</td><td>\'+fmt(n.value)+\'</td></tr>\'; });\n      html+=\'</tbody></table>\';\n    }\n    detail.innerHTML = html;\n  }\n  upBtn.addEventListener(\'click\', function(){ if(stack.length){ stack.pop(); render(); } });\n  render();\n})();\n'

_TIMELINE_JS module-attribute

_TIMELINE_JS = '\n(function(){\n  var wrap = document.currentScript.closest(\'.tl-wrap\');\n  if(!wrap || wrap._tlInit) return; wrap._tlInit = true;\n  var data = JSON.parse(wrap.querySelector(\'.tl-data\').textContent || \'{}\');\n  var svg = wrap.querySelector(\'svg.timeline\');\n  var tip = wrap.querySelector(\'.tl-tip\');\n  var rows = (data.rows||[]).map(function(r){\n    return {view:r.view, s:Date.parse(r.start), e:Date.parse(r.end||r.start),\n            status:r.status, filter:r.filter||\'\'};\n  }).filter(function(r){ return !isNaN(r.s); });\n  if(!rows.length){ svg.outerHTML=\'<p class="muted">No run history.</p>\'; return; }\n  var views=[]; rows.forEach(function(r){ if(views.indexOf(r.view)<0) views.push(r.view); });\n  var dmin=Math.min.apply(null, rows.map(function(r){return r.s;}));\n  var dmax=Math.max.apply(null, rows.map(function(r){return Math.max(r.e,r.s);}));\n  if(dmax<=dmin) dmax=dmin+86400000;\n  var view0=dmin, view1=dmax; // current zoom window\n  var LW=210, RW=20, RH=20, GAP=6, TOP=8, AXIS=22;\n  var W=980, TRACK=W-LW-RW;\n  var H=TOP+views.length*(RH+GAP)+AXIS;\n  function esc(s){ return String(s==null?\'\':s).replace(/&/g,\'&amp;\').replace(/</g,\'&lt;\').replace(/>/g,\'&gt;\'); }\n  function clr(st){ st=String(st||\'\').toUpperCase();\n    return st.indexOf(\'COMPLETED\')===0?\'#27ae60\':(st.indexOf(\'FAIL\')===0?\'#e74c3c\':\'#95a5a6\'); }\n  function x(t){ return LW+((t-view0)/(view1-view0))*TRACK; }\n  function fmtT(ms){ var d=new Date(ms); return d.toISOString().slice(0,16).replace(\'T\',\' \'); }\n  function ticks(){\n    var span=view1-view0, n=6, out=\'\';\n    for(var i=0;i<=n;i++){ var t=view0+span*i/n; var px=LW+(TRACK*i/n);\n      out+=\'<line x1="\'+px+\'" y1="\'+TOP+\'" x2="\'+px+\'" y2="\'+(H-AXIS)+\'" stroke="#eef1f5"/>\'\n         +\'<text x="\'+px+\'" y="\'+(H-6)+\'" text-anchor="middle" class="tm-sub" fill="#5A6B7B">\'+esc(fmtT(t))+\'</text>\';\n    }\n    return out;\n  }\n  function render(){\n    svg.setAttribute(\'viewBox\',\'0 0 \'+W+\' \'+H);\n    var out=ticks(); var y=TOP;\n    views.forEach(function(v){\n      out+=\'<text x="\'+(LW-10)+\'" y="\'+(y+RH/2+4)+\'" text-anchor="end" class="tl-label">\'+esc(v.length>30?v.slice(0,29)+\'…\':v)+\'</text>\';\n      out+=\'<rect x="\'+LW+\'" y="\'+y+\'" width="\'+TRACK+\'" height="\'+RH+\'" rx="4" fill="#f5f7fa"/>\';\n      y+=RH+GAP;\n    });\n    rows.forEach(function(r){\n      if(r.e<view0 || r.s>view1) return;\n      var yi=views.indexOf(r.view); var ry=TOP+yi*(RH+GAP);\n      var xs=Math.max(LW, x(r.s)), xe=Math.min(LW+TRACK, x(Math.max(r.e,r.s)));\n      var w=Math.max(2.5, xe-xs);\n      out+=\'<rect class="tl-run" x="\'+xs.toFixed(1)+\'" y="\'+ry+\'" width="\'+w.toFixed(1)+\'" height="\'+RH+\'" rx="2" fill="\'+clr(r.status)+\'" \'\n         +\'data-t="\'+r.s+\'" data-st="\'+esc(r.status)+\'" data-f="\'+esc(r.filter)+\'" data-v="\'+esc(r.view)+\'"/>\';\n    });\n    svg.innerHTML=out;\n    Array.prototype.forEach.call(svg.querySelectorAll(\'.tl-run\'), function(el){\n      el.addEventListener(\'mousemove\', function(ev){\n        tip.style.display=\'block\';\n        var b=wrap.getBoundingClientRect();\n        tip.style.left=(ev.clientX-b.left+12)+\'px\'; tip.style.top=(ev.clientY-b.top+12)+\'px\';\n        tip.innerHTML=\'<b>\'+esc(el.getAttribute(\'data-v\'))+\'</b><br>\'+esc(fmtT(+el.getAttribute(\'data-t\')))\n          +\'<br>\'+esc(el.getAttribute(\'data-st\'))\n          +(el.getAttribute(\'data-f\')?(\'<br>filter: \'+esc(el.getAttribute(\'data-f\'))):\'\');\n      });\n      el.addEventListener(\'mouseleave\', function(){ tip.style.display=\'none\'; });\n    });\n  }\n  // wheel zoom (centered on cursor)\n  svg.addEventListener(\'wheel\', function(ev){\n    ev.preventDefault();\n    var b=svg.getBoundingClientRect();\n    var frac=Math.min(1,Math.max(0,((ev.clientX-b.left)/b.width*W - LW)/TRACK));\n    var c=view0+(view1-view0)*frac;\n    var k=ev.deltaY<0?0.8:1.25;\n    var nv0=c-(c-view0)*k, nv1=c+(view1-c)*k;\n    if(nv1-nv0>60000){ view0=Math.max(dmin,nv0); view1=Math.min(dmax,nv1); render(); }\n  }, {passive:false});\n  // drag to zoom\n  var dragX=null, band=null;\n  svg.addEventListener(\'mousedown\', function(ev){ dragX=ev.clientX;\n    band=document.createElementNS(\'http://www.w3.org/2000/svg\',\'rect\'); });\n  svg.addEventListener(\'mouseup\', function(ev){\n    if(dragX!==null){\n      var b=svg.getBoundingClientRect();\n      var f0=Math.min(dragX,ev.clientX), f1=Math.max(dragX,ev.clientX);\n      if(f1-f0>6){\n        var t0=view0+(view1-view0)*Math.max(0,((f0-b.left)/b.width*W-LW)/TRACK);\n        var t1=view0+(view1-view0)*Math.min(1,((f1-b.left)/b.width*W-LW)/TRACK);\n        if(t1-t0>60000){ view0=t0; view1=t1; render(); }\n      }\n      dragX=null;\n    }\n  });\n  svg.addEventListener(\'dblclick\', function(){ view0=dmin; view1=dmax; render(); });\n  wrap.querySelector(\'.tl-reset\').addEventListener(\'click\', function(){ view0=dmin; view1=dmax; render(); });\n  render();\n})();\n'

_GUIDE module-attribute

_GUIDE = {'storage': "<p>Each <code>FS_T_*</code> table physically stores several features in one table. The bar/tile size is the table's <b>NONSEQUENCED VALIDTIME</b> row count — every temporal version, current and superseded.</p><ul><li><span class='o'>Orange</span> = <b>CURRENT VALIDTIME</b> rows (the live slice served today). <span class='n'>Navy</span> = <b>history</b> rows (superseded versions). A tall navy segment means deep temporal history — candidate for review if storage is a concern.</li><li>The treemap tile area is the stored-row share; deeper orange = a higher history ratio.</li><li>Counts come from the last <i>Build storage counts</i> pass and run fast when stats/PI/partitions are in place.</li></ul>", 'workflow': '<p>How the pipeline is built, checked without any language model. <b>Entity keys</b>: a key column that the other key columns determine (observed over the whole stored history) adds nothing to uniqueness and widens the primary index, so a lookup by the real key cannot use it; a key column that changed over time starts a new entity at each change. These need the <b>Scan entity keys</b> button of the Workflow assessment panel. <b>Order and synchronisation</b>: at each business date, did every process start after the processes it reads had finished, and were they recomputed later? Does a process follow the same TimeManager as its upstream, and does a TimeManager process read a dataset that moves with its date?</p><p>A dependency observed in the data is not a rule: confirm with the business before changing an entity. <b>Not verified</b> lists the checks that could not run (lineage graph missing, data dictionary unreadable): they are not an all-clear.</p>', 'time_drivers': '<p>A process computes at a business date when it is driven by a <b>TimeManager</b>. <b>Declared</b> is the pairing recorded for the process (by <code>upload_features(..., time_manager=)</code>, <code>run(..., time_manager=)</code> or <code>roll_out</code>); <code>run(pid)</code> uses it. <b>Reads</b> is the manager its view really depends on, found in the lineage graph through any number of intermediate views. When they differ the values carry one date while being computed at another: the Fix column says how to align them.</p><p><b>Synchronised</b> processes read the same manager view: one cursor, so they always compute the same date. <b>Soft clones</b> read the same dates through views with their own cursor (<code>split_time_manager</code> makes one); <b>hard clones</b> hold a copy of the dates. A generated parallel script runs synchronised processes in lockstep.</p>', 'consistency': "<p>A <b>feature</b> is the triplet (data domain, entity, feature name): the same name under two entities, or in two domains, is two features. Every count above is read from the catalogs at the <b>catalog valid time</b> shown in the header - the current one, or the date chosen with <b>Valid time</b>.</p><p>The line at the top reconciles the numbers other panels show: the Documentation coverage panel counts <b>process outputs</b> (one per name a current process lists), so a feature no current process produces is in the catalog but not there, and a feature listed by two processes is there twice.</p><p><span class='r'>error</span> - something is broken (a table or view is gone, a dataset reads features the catalog no longer has). <span class='y'>warning</span> - something will not refresh or costs space for nothing. <b>info</b> - history worth knowing. The <b>Fix</b> column names the command; nothing on this page writes.</p><p>Table and view existence is checked in the data dictionary <b>now</b>, whatever the valid time. FEATURE_IDs stored without a catalog row are found only by <b>Scan storage for orphans</b>, which reads every storage table once.</p>", 'orphans': '<p>An <b>orphan</b> is a <code>FEATURE_ID</code> physically present in a storage table but no longer in <code>FS_FEATURE_CATALOG</code> — a feature de-registered without purging its stored rows. This is <b>report-only</b>; nothing is deleted.</p><p>Orphan rows still consume space and history depth. Investigate before purging.</p>', 'statistics': '<p>The optimizer statistics of each <code>FS_T_*</code> table, compared with the set ingestion defines when it first collects them. Ingestion afterwards only <b>refreshes</b> what a table already carries, so a statistic added to that set later is missing on older tables until it is defined once.</p><ul><li><b>MISSING</b>: some expected statistics are not defined. Without the <code>(entity, FEATURE_ID)</code> one the optimizer can misestimate dataset lookups by orders of magnitude.</li><li><b>NO_STATS</b>: nothing defined yet (an empty or never-ingested table).</li><li><b>TABLE_MISSING</b>: the feature catalog still lists the table, but it no longer exists in the database (dropped outside tdfs4ds). Nothing is collected on it.</li><li>Multi-column statistics refresh only once 15% of the table has changed (<code>THRESHOLD 15 PERCENT</code>), so their date can be older than the others.</li></ul><p>To fix: <i>Maintenance → Storage statistics → Add missing</i>, or <code>tdfs4ds.check_storage_statistics(add_missing=True)</code>.</p>', 'run_history': "<p>The execution record of each registered process from the follow-up table.</p><ul><li><b>Timeline</b>: each bar is one run, positioned on a shared time axis and coloured <span class='g'>green=completed</span> / <span class='r'>red=failed</span> / <span class='y'>grey=running</span>. Gaps reveal cadence; clustered red reveals instability.</li><li><b>Calendar</b>: day × process — a quick scan for stale processes (no recent cells) and failure days.</li></ul>", 'freshness': '<p>A process that runs <b>segmented</b> (a FilterManager or date-part loop) can be fresh for one segment and stale for another. These alerts flag the <b>stalest segment</b> per process, so a partially-stale process is never read as fully fresh.</p>', 'lineage': '<p>How connected each process/dataset is in the persisted lineage graph (<code>FS_LINEAGE_GRAPH</code>): <b>upstream</b> = objects it depends on, <b>downstream</b> = objects that depend on it. High downstream = high blast radius: a change here re-documents / can break many consumers.</p>'}

CSS module-attribute

CSS = '\n*{box-sizing:border-box}\n.td-report{--navy:#00233C;--orange:#FF5F00;--ink:#2c3e50;--muted:#5A6B7B;\n  --line:#E3E8EE;--bg:#f5f7fa;--panel:#ffffff;\n  font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;\n  color:var(--ink);max-width:1080px;margin:0 auto;padding:8px 4px 60px}\n.td-report h1{margin:.2em 0 .1em;font-size:28px;letter-spacing:-.02em;color:#fff}\n.td-report h2{margin:0 0 6px;font-size:19px;color:var(--navy);letter-spacing:-.01em}\n.td-report h4{margin:16px 0 8px;font-size:12px;color:var(--muted);\n  text-transform:uppercase;letter-spacing:.06em}\n.td-report .muted{color:var(--muted);margin:.2em 0 12px;font-size:13.5px}\n.td-report code{background:#eef1f5;border-radius:5px;padding:1px 6px;\n  font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;color:#c0392b}\n\n.td-report header.hero{background:linear-gradient(135deg,#00233C,#004069);\n  border-radius:16px;padding:26px 30px;margin-bottom:22px;position:relative;overflow:hidden}\n.td-report header.hero:before{content:"";position:absolute;right:-50px;top:-50px;\n  width:200px;height:200px;background:radial-gradient(circle,rgba(255,95,0,.30),transparent 65%)}\n.td-report .kicker{letter-spacing:.16em;text-transform:uppercase;font-size:11.5px;\n  color:var(--orange);font-weight:700}\n.td-report .schema{font-size:17px;color:#fff;font-weight:600;margin-top:2px}\n.td-report .meta-line{color:#bcd0e0;font-size:13px;margin-top:10px;display:flex;\n  flex-wrap:wrap;gap:6px 18px}\n.td-report .meta-line b{color:#fff;font-weight:600}\n\n.td-report .kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));\n  gap:13px;margin:0 0 20px}\n.td-report .kpi{background:var(--panel);border:1px solid var(--line);border-radius:13px;\n  padding:15px 16px;position:relative;box-shadow:0 1px 4px rgba(0,35,60,.06)}\n.td-report .kpi:before{content:"";position:absolute;left:0;top:13px;bottom:13px;width:3px;\n  border-radius:3px;background:var(--orange)}\n.td-report .kpi-label{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}\n.td-report .kpi-value{font-size:25px;font-weight:800;margin-top:5px;color:var(--navy)}\n.td-report .kpi-sub{font-size:11.5px;color:var(--muted);margin-top:3px}\n\n.td-report section.card{background:var(--panel);border:1px solid var(--line);\n  border-radius:15px;padding:20px 22px;margin:18px 0;box-shadow:0 1px 4px rgba(0,35,60,.05)}\n\n.td-report .facts{display:flex;flex-wrap:wrap;gap:12px}\n.td-report .fact{flex:1;min-width:210px;background:#f8fafc;border:1px solid var(--line);\n  border-radius:11px;padding:13px 16px}\n.td-report .fact-h{font-weight:700;color:var(--navy);font-size:.92rem}\n.td-report .fact-d{color:#334155;font-size:.85rem;margin-top:4px}\n\n.td-report table.data{width:100%;border-collapse:collapse;margin-top:6px;font-size:13.5px}\n.td-report table.data th{text-align:right;color:var(--muted);font-weight:600;font-size:11.5px;\n  text-transform:uppercase;letter-spacing:.04em;padding:6px 10px;border-bottom:2px solid var(--orange)}\n.td-report table.data th:first-child{text-align:left}\n.td-report table.data td{text-align:right;padding:7px 10px;border-bottom:1px solid var(--line)}\n.td-report table.data td.name{text-align:left;font-weight:600;color:var(--navy)}\n.td-report table.data tr:hover td{background:#f8fafc}\n.td-report .mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;color:#334155}\n\n.td-report .chart-wrap{margin-top:10px}\n.td-report svg.barchart,.td-report svg.treemap,.td-report svg.timeline{width:100%;height:auto;display:block}\n.td-report .bar-label{fill:var(--muted);font-size:12.5px}\n.td-report .bar-track{fill:#eef1f5}\n.td-report .bar-value{fill:var(--navy);font-size:12px;font-weight:600}\n/* Treemap tiles: dark text on a legible orange for contrast */\n.td-report .tm-label{fill:#00233C;font-size:12px;font-weight:700}\n.td-report .tm-sub{fill:#00233C;font-size:11px;opacity:.8}\n.td-report svg.timeline .tm-sub{font-size:10px}\n\n/* Drillable treemap widget */\n.td-report .tm-wrap{margin-top:10px}\n.td-report .tm-bc{display:flex;align-items:center;gap:10px;margin-bottom:8px;font-size:13px;color:var(--navy)}\n.td-report .tm-up{cursor:pointer;border:1px solid var(--line);background:#fff;border-radius:6px;\n  padding:3px 10px;font-size:12px;color:var(--navy)}\n.td-report .tm-up:disabled{opacity:.4;cursor:default}\n.td-report .tm-crumb{font-weight:600}\n.td-report svg.treemap .tm-can{cursor:pointer}\n.td-report svg.treemap .tm-tile rect{transition:opacity .12s}\n.td-report svg.treemap .tm-can:hover rect{opacity:.82;stroke:#00233C;stroke-width:2}\n.td-report .tm-detail{margin-top:12px}\n\n/* Calendar — small dedicated labels so the dense grid stays readable */\n.td-report .cal-label{fill:var(--muted);font-size:10.5px}\n.td-report .cal-head{fill:var(--muted);font-size:9.5px}\n\n/* Interactive timeline */\n.td-report .tl-wrap{position:relative;margin-top:10px}\n.td-report .tl-ctl{display:flex;align-items:center;gap:12px;margin-bottom:6px}\n.td-report .tl-reset{cursor:pointer;border:1px solid var(--line);background:#fff;border-radius:6px;\n  padding:3px 10px;font-size:12px;color:var(--navy)}\n.td-report .tl-hint{font-size:11.5px;color:var(--muted)}\n.td-report .tl-label{fill:var(--muted);font-size:11.5px}\n.td-report svg.timeline .tl-run{cursor:pointer}\n.td-report svg.timeline .tl-run:hover{stroke:#00233C;stroke-width:1.5}\n.td-report .tl-tip{position:absolute;z-index:5;pointer-events:none;background:#00233C;color:#fff;\n  font-size:11.5px;line-height:1.4;padding:6px 9px;border-radius:7px;max-width:320px;\n  box-shadow:0 2px 8px rgba(0,0,0,.25)}\n\n.td-report .notice{padding:11px 14px;border-radius:10px;background:#f8fafc;\n  border:1px solid var(--line);font-size:13.5px;color:var(--muted)}\n.td-report .notice-warn{border-color:rgba(231,76,60,.4);background:rgba(231,76,60,.08);color:#a93226}\n.td-report .notice-ok{border-color:rgba(39,174,96,.4);background:rgba(39,174,96,.08);color:#1e8449}\n\n.td-report .legend{display:flex;gap:16px;flex-wrap:wrap;margin-top:8px;font-size:12.5px;color:var(--muted)}\n.td-report .legend span{display:inline-flex;align-items:center;gap:6px}\n.td-report .dot{width:11px;height:11px;border-radius:3px;display:inline-block}\n\n.td-report details.plan{margin-top:12px}\n.td-report details.plan>summary{cursor:pointer;color:var(--orange);font-size:13px;font-weight:600}\n.td-report details.explain{margin-top:14px;border-top:1px dashed var(--line);padding-top:10px}\n.td-report details.explain>summary{cursor:pointer;color:var(--orange);font-size:13px;\n  font-weight:600;list-style:none;display:inline-flex;align-items:center;gap:7px;user-select:none}\n.td-report details.explain>summary::-webkit-details-marker{display:none}\n.td-report details.explain>summary:before{content:"?";display:inline-flex;align-items:center;\n  justify-content:center;width:18px;height:18px;border-radius:50%;background:rgba(255,95,0,.15);\n  color:var(--orange);font-size:12px;font-weight:700}\n.td-report .explain-body{font-size:13.5px;color:#334155;background:#f8fafc;border:1px solid var(--line);\n  border-radius:10px;padding:13px 16px;margin-top:8px}\n.td-report .explain-body p{margin:.2em 0 .7em}.td-report .explain-body p:last-child{margin-bottom:0}\n.td-report .explain-body ul{margin:.3em 0 .5em;padding-left:20px}\n.td-report .explain-body li{margin:5px 0}\n.td-report .explain-body code{background:#eef1f5;border:1px solid var(--line)}\n.td-report .explain-body .g{color:#1e8449;font-weight:600}\n.td-report .explain-body .r{color:#a93226;font-weight:600}\n.td-report .explain-body .y{color:#b9770e;font-weight:600}\n.td-report .explain-body .o{color:var(--orange);font-weight:600}\n.td-report .explain-body .n{color:var(--navy);font-weight:600}\n\n/* the inventory tree reuses _dash_tree_html\'s own scoped .td-tree styles */\n.td-report footer{color:var(--muted);font-size:12.5px;text-align:center;margin-top:30px;line-height:1.7}\n'

_esc

_esc(x)

_fmt_int

_fmt_int(v)

_fmt_bytes

_fmt_bytes(v)

_first_line

_first_line(s)

_explainer

_explainer(body_html)

A collapsible 'How to read this' block, appended to a section.

_kpi_card

_kpi_card(label, value, sub=None, cls='')

_svg_stacked_bars

_svg_stacked_bars(rows, *, height=30, gap=9, label_w=230, track_w=520)

Horizontal stacked bars: each row = (label, current, history, hover).

Stack width = current + history; current drawn in orange, history in navy. Values None/0 tolerated. Returns an inline <svg> string.

_drill_treemap

_drill_treemap(storage, hierarchy)

Drillable two-level treemap (tables → processes → features) as a self- contained widget: an inline JSON model + an SVG/table container + inline JS that lays out tiles and handles click-drill + a synced detail table.

No external libs / no fetch — works on-screen (injected) and in the export.

_truncate

_truncate(s, n)

_interactive_timeline

_interactive_timeline(rh)

Zoomable run timeline as a self-contained widget.

rh = {rows:[{view,start,end,status,filter}], t_min, t_max}. The browser script lays out one lane per process on a datetime axis, supports wheel-zoom + drag-to-zoom + reset, and shows datetime · status · filter/TimeManager params in a hover tooltip. No external libs / no fetch.

_svg_calendar

_svg_calendar(cal, *, cell=15, gap=3, label_w=170)

Day × process status grid. cal = {view_name: {day_str: ok_bool}}.

Uses a small dedicated label class so the dense grid stays readable.

_status_color

_status_color(status)

_render_kpis

_render_kpis(inv)

_render_insights

_render_insights(insights)

_render_inventory

_render_inventory(tree_html)

_render_storage

_render_storage(storage, hierarchy=None)

_render_consistency

_render_consistency(body)

_render_time_drivers

_render_time_drivers(body)

_render_workflow

_render_workflow(body)

_render_orphans

_render_orphans(orphans)

_render_statistics

_render_statistics(stats)

_render_run_history

_render_run_history(rh, window_days=90)

_render_freshness

_render_freshness(alerts)

_render_lineage

_render_lineage(counts)

_status_cell

_status_cell(status) -> str

A long status (a FAILED error) is folded, never cut: the whole text stays in the page.

_render_doc_activity

_render_doc_activity(activity)

Documentation activity (0.3.2.1): who changed the documentation, and how.

build_dashboard_report

build_dashboard_report(data)

Render data (a plain dict) to one self-contained interactive HTML doc.

write_dashboard_report

write_dashboard_report(data, out_path=None)

Render data to HTML and write it to disk; return the file path.

When out_path is None, writes under {tdfs4ds.DASHBOARD_REPORT_DIR}/ with a timestamped filename (directory created on demand).