sparkforensics-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +147 -0
- package/lib/static-files.js +50 -0
- package/package.json +28 -0
- package/public/assets/AutoscalingChurn-CP5OjN6v.js +1 -0
- package/public/assets/CacheUtilization-BjyawXL6.js +1 -0
- package/public/assets/CachingOpportunity-DV2ABG2G.js +1 -0
- package/public/assets/ConfigAudit-DMiBhAoG.js +1 -0
- package/public/assets/CoreUsageArea-CGYIgPnt.js +1 -0
- package/public/assets/Dashboard-D1_IrWRs.js +12 -0
- package/public/assets/ExecutorTimeline-DkaAGqQT.js +1 -0
- package/public/assets/Failures-Crngoicu.js +1 -0
- package/public/assets/GcPressure-CFXm4BnA.js +1 -0
- package/public/assets/IncompleteRun-BWEJTb13.js +1 -0
- package/public/assets/JobFailures-BmjxCA-h.js +1 -0
- package/public/assets/MemoryUtilization-Bu2uQJbd.js +1 -0
- package/public/assets/PlanExplorer-DKUwF0uz.js +1 -0
- package/public/assets/PlanGraphRoute-BjPSugIG.js +1 -0
- package/public/assets/ShuffleIO--Rur3neu.js +1 -0
- package/public/assets/Spill-DIMpt8c2.js +1 -0
- package/public/assets/TaskSkew-BDr1DbeI.js +1 -0
- package/public/assets/charts-vendor-NMqKV0H4.js +59 -0
- package/public/assets/index-4id52G-A.js +9 -0
- package/public/assets/index-D9kwVYIO.css +1 -0
- package/public/assets/parser-worker-00O9WEQC.js +65 -0
- package/public/assets/plan-graph-vendor-BnuhLJ6X.css +1 -0
- package/public/assets/plan-graph-vendor-vZQHG2EJ.js +7 -0
- package/public/assets/react-vendor-u4pyvC5Y.js +9 -0
- package/public/assets/useRecentFiles-46iBU9zJ.js +5 -0
- package/public/docs/404.html +26 -0
- package/public/docs/assets/app.Bxy4_HVA.js +1 -0
- package/public/docs/assets/chunks/@localSearchIndexroot.DclgKtlT.js +1 -0
- package/public/docs/assets/chunks/VPLocalSearchBox.L5jNOIR3.js +9 -0
- package/public/docs/assets/chunks/framework.CNDaxUod.js +20 -0
- package/public/docs/assets/chunks/theme.CoT6y5bf.js +2 -0
- package/public/docs/assets/contributor-guide_architecture_board-widgets.md.BtjoE_1k.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_board-widgets.md.BtjoE_1k.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_detector-contract.md.DQyCHQNM.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_detector-contract.md.DQyCHQNM.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_drill-down.md.BKfyDQck.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_drill-down.md.BKfyDQck.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.CzlXOnCq.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.CzlXOnCq.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_index.md.94lNbF-4.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_index.md.94lNbF-4.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_overview.md.mQFlN66o.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_overview.md.mQFlN66o.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_state-and-history.md.DaqttKgQ.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_state-and-history.md.DaqttKgQ.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.Bk6Iyj9S.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.Bk6Iyj9S.lean.js +1 -0
- package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.BaS_L10g.js +6 -0
- package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.BaS_L10g.lean.js +1 -0
- package/public/docs/assets/contributor-guide_contributing.md.C-lKs70I.js +1 -0
- package/public/docs/assets/contributor-guide_contributing.md.C-lKs70I.lean.js +1 -0
- package/public/docs/assets/contributor-guide_development-setup.md.CpUMVtIK.js +12 -0
- package/public/docs/assets/contributor-guide_development-setup.md.CpUMVtIK.lean.js +1 -0
- package/public/docs/assets/contributor-guide_testing.md.C0q-BJfl.js +1 -0
- package/public/docs/assets/contributor-guide_testing.md.C0q-BJfl.lean.js +1 -0
- package/public/docs/assets/index.md.CETnDZzR.js +1 -0
- package/public/docs/assets/index.md.CETnDZzR.lean.js +1 -0
- package/public/docs/assets/inter-italic-cyrillic-ext.r48I6akx.woff2 +0 -0
- package/public/docs/assets/inter-italic-cyrillic.By2_1cv3.woff2 +0 -0
- package/public/docs/assets/inter-italic-greek-ext.1u6EdAuj.woff2 +0 -0
- package/public/docs/assets/inter-italic-greek.DJ8dCoTZ.woff2 +0 -0
- package/public/docs/assets/inter-italic-latin-ext.CN1xVJS-.woff2 +0 -0
- package/public/docs/assets/inter-italic-latin.C2AdPX0b.woff2 +0 -0
- package/public/docs/assets/inter-italic-vietnamese.BSbpV94h.woff2 +0 -0
- package/public/docs/assets/inter-roman-cyrillic-ext.BBPuwvHQ.woff2 +0 -0
- package/public/docs/assets/inter-roman-cyrillic.C5lxZ8CY.woff2 +0 -0
- package/public/docs/assets/inter-roman-greek-ext.CqjqNYQ-.woff2 +0 -0
- package/public/docs/assets/inter-roman-greek.BBVDIX6e.woff2 +0 -0
- package/public/docs/assets/inter-roman-latin-ext.4ZJIpNVo.woff2 +0 -0
- package/public/docs/assets/inter-roman-latin.Di8DUHzh.woff2 +0 -0
- package/public/docs/assets/inter-roman-vietnamese.BjW4sHH5.woff2 +0 -0
- package/public/docs/assets/style.D2mY0HUs.css +1 -0
- package/public/docs/assets/user-guide_getting-started.md.BJdD7a7_.js +3 -0
- package/public/docs/assets/user-guide_getting-started.md.BJdD7a7_.lean.js +1 -0
- package/public/docs/assets/user-guide_mcp-tools.md.DwYI1d6C.js +95 -0
- package/public/docs/assets/user-guide_mcp-tools.md.DwYI1d6C.lean.js +1 -0
- package/public/docs/assets/user-guide_run-comparison.md.5-oaOfTu.js +1 -0
- package/public/docs/assets/user-guide_run-comparison.md.5-oaOfTu.lean.js +1 -0
- package/public/docs/assets/user-guide_understanding-findings.md.CK2WZ0Tf.js +1 -0
- package/public/docs/assets/user-guide_understanding-findings.md.CK2WZ0Tf.lean.js +1 -0
- package/public/docs/contributor-guide/architecture/board-widgets.html +29 -0
- package/public/docs/contributor-guide/architecture/detector-contract.html +29 -0
- package/public/docs/contributor-guide/architecture/drill-down.html +29 -0
- package/public/docs/contributor-guide/architecture/impact-estimation.html +29 -0
- package/public/docs/contributor-guide/architecture/index.html +29 -0
- package/public/docs/contributor-guide/architecture/overview.html +29 -0
- package/public/docs/contributor-guide/architecture/state-and-history.html +29 -0
- package/public/docs/contributor-guide/architecture/widget-rendering.html +29 -0
- package/public/docs/contributor-guide/architecture/worker-protocol.html +34 -0
- package/public/docs/contributor-guide/contributing.html +29 -0
- package/public/docs/contributor-guide/development-setup.html +40 -0
- package/public/docs/contributor-guide/testing.html +29 -0
- package/public/docs/favicon.svg +4 -0
- package/public/docs/hashmap.json +1 -0
- package/public/docs/index.html +29 -0
- package/public/docs/user-guide/getting-started.html +31 -0
- package/public/docs/user-guide/mcp-tools.html +123 -0
- package/public/docs/user-guide/run-comparison.html +29 -0
- package/public/docs/user-guide/understanding-findings.html +29 -0
- package/public/docs/vp-icons.css +0 -0
- package/public/favicon.svg +4 -0
- package/public/index.html +36 -0
- package/public/vendor/spark-doc/anchors.json +205 -0
- package/public/vendor/spark-doc/index.html +4202 -0
- package/public/vendor/spark-doc/landing.html +418 -0
- package/public/vendor/spark-doc/meta.html +970 -0
- package/vendor-core/analyzer.js +167 -0
- package/vendor-core/assert-never.js +3 -0
- package/vendor-core/cli/budgets.js +203 -0
- package/vendor-core/cli/collect-run.js +107 -0
- package/vendor-core/core-count.js +68 -0
- package/vendor-core/core-locality-ratio.js +54 -0
- package/vendor-core/core-time-series.js +92 -0
- package/vendor-core/core-usage-locality.js +70 -0
- package/vendor-core/detectors.js +1989 -0
- package/vendor-core/docs-config.js +72 -0
- package/vendor-core/docs-site-config.js +23 -0
- package/vendor-core/efficiency-model.js +62 -0
- package/vendor-core/etl-phases.js +28 -0
- package/vendor-core/event-handlers.js +906 -0
- package/vendor-core/event-schemas.js +405 -0
- package/vendor-core/evidence-availability.js +121 -0
- package/vendor-core/evidence-report.js +459 -0
- package/vendor-core/finding-action-label.js +97 -0
- package/vendor-core/finding-filter-predicate.js +38 -0
- package/vendor-core/format-utils.js +167 -0
- package/vendor-core/impact-band.js +50 -0
- package/vendor-core/impact-estimator.js +428 -0
- package/vendor-core/ingest.js +139 -0
- package/vendor-core/job-groups.js +30 -0
- package/vendor-core/load-vendored.js +24 -0
- package/vendor-core/lz4-block.js +135 -0
- package/vendor-core/mcp-error.js +3 -0
- package/vendor-core/mcp-server-factory.js +115 -0
- package/vendor-core/mcp-tools.js +331 -0
- package/vendor-core/model-assembler.js +76 -0
- package/vendor-core/occupancy.js +202 -0
- package/vendor-core/parser-worker.js +249 -0
- package/vendor-core/plan-dot.js +25 -0
- package/vendor-core/plan-duration-attribution.js +185 -0
- package/vendor-core/plan-graph-model.js +171 -0
- package/vendor-core/plan-node-detail.js +159 -0
- package/vendor-core/plan-summary.js +233 -0
- package/vendor-core/plan-tree-walk.js +29 -0
- package/vendor-core/proxy.js +157 -0
- package/vendor-core/recommendation-rollup.js +197 -0
- package/vendor-core/redact.js +175 -0
- package/vendor-core/rolling-log-reassembly.js +52 -0
- package/vendor-core/run-aggregates.js +44 -0
- package/vendor-core/run-comparison.js +458 -0
- package/vendor-core/scaling-sim.js +73 -0
- package/vendor-core/session-snapshot.js +79 -0
- package/vendor-core/shs-fetch.js +196 -0
- package/vendor-core/shs-load.js +121 -0
- package/vendor-core/shs-request.js +101 -0
- package/vendor-core/shs-schemas.js +13 -0
- package/vendor-core/snappy-block.js +140 -0
- package/vendor-core/stage-quantiles.js +199 -0
- package/vendor-core/threshold-summary.js +35 -0
- package/vendor-core/types.js +286 -0
- package/vendor-core/vendor/fflate.js +2695 -0
- package/vendor-core/vendor/fzstd.js +768 -0
- package/vendor-core/wall-clock.js +36 -0
- package/vendor-core/wasted-core-hours.js +68 -0
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/chunks/VPLocalSearchBox.L5jNOIR3.js","assets/chunks/framework.CNDaxUod.js"])))=>i.map(i=>d[i]);
|
|
2
|
+
import{d as p,c as u,r as c,n as N,o as s,a as j,t as M,b as _,w as h,T as ue,e as m,_ as g,u as Ce,i as He,f as Be,g as de,h as y,j as d,k as i,l as z,m as se,p as S,q as F,s as X,v as U,x as ve,y as fe,z as Ee,A as Fe,F as x,B as A,C as W,D as Y,E as k,G as $e,H as B,I as ye,J as Q,K as G,L as Z,M as De,N as Pe,O as ie,P as Le,Q as Ve,R as ee,S as Oe,U as Ge,V as Ue,W as Se,X as Te,Y as je,Z as ze,$ as We,a0 as Ke,a1 as qe}from"./framework.CNDaxUod.js";const Re=p({__name:"VPBadge",props:{text:{},type:{default:"tip"}},setup(e){return(t,n)=>(s(),u("span",{class:N(["VPBadge",e.type])},[c(t.$slots,"default",{},()=>[j(M(e.text),1)])],2))}}),Je={key:0,class:"VPBackdrop"},Xe=p({__name:"VPBackdrop",props:{show:{type:Boolean}},setup(e){return(t,n)=>(s(),_(ue,{name:"fade"},{default:h(()=>[e.show?(s(),u("div",Je)):m("",!0)]),_:1}))}}),Ye=g(Xe,[["__scopeId","data-v-c79a1216"]]),L=Ce;function Qe(e,t){let n,a=!1;return()=>{n&&clearTimeout(n),a?n=setTimeout(e,t):(e(),(a=!0)&&setTimeout(()=>a=!1,t))}}function re(e){return e.startsWith("/")?e:`/${e}`}function he(e){const{pathname:t,search:n,hash:a,protocol:o}=new URL(e,"http://a.com");if(He(e)||e.startsWith("#")||!o.startsWith("http")||!Be(t))return e;const{site:r}=L(),l=t.endsWith("/")||t.endsWith(".html")?e:e.replace(/(?:(^\.+)\/)?.*$/,`$1${t.replace(/(\.md)?$/,r.value.cleanUrls?"":".html")}${n}${a}`);return de(l)}function q({correspondingLink:e=!1}={}){const{site:t,localeIndex:n,page:a,theme:o,hash:r}=L(),l=y(()=>{var f,$;return{label:(f=t.value.locales[n.value])==null?void 0:f.label,link:(($=t.value.locales[n.value])==null?void 0:$.link)||(n.value==="root"?"/":`/${n.value}/`)}});return{localeLinks:y(()=>Object.entries(t.value.locales).flatMap(([f,$])=>l.value.label===$.label?[]:{text:$.label,link:Ze($.link||(f==="root"?"/":`/${f}/`),o.value.i18nRouting!==!1&&e,a.value.relativePath.slice(l.value.link.length-1),!t.value.cleanUrls)+r.value})),currentLang:l}}function Ze(e,t,n,a){return t?e.replace(/\/$/,"")+re(n.replace(/(^|\/)index\.md$/,"$1").replace(/\.md$/,a?".html":"")):e}const et={class:"NotFound"},tt={class:"code"},nt={class:"title"},at={class:"quote"},ot={class:"action"},st=["href","aria-label"],it=p({__name:"NotFound",setup(e){const{theme:t}=L(),{currentLang:n}=q();return(a,o)=>{var r,l,v,f,$;return s(),u("div",et,[d("p",tt,M(((r=i(t).notFound)==null?void 0:r.code)??"404"),1),d("h1",nt,M(((l=i(t).notFound)==null?void 0:l.title)??"PAGE NOT FOUND"),1),o[0]||(o[0]=d("div",{class:"divider"},null,-1)),d("blockquote",at,M(((v=i(t).notFound)==null?void 0:v.quote)??"But if you don't change your direction, and if you keep looking, you may end up where you are heading."),1),d("div",ot,[d("a",{class:"link",href:i(de)(i(n).link),"aria-label":((f=i(t).notFound)==null?void 0:f.linkLabel)??"go to home"},M((($=i(t).notFound)==null?void 0:$.linkText)??"Take me home"),9,st)])])}}}),rt=g(it,[["__scopeId","data-v-d6be1790"]]);function Ne(e,t){if(Array.isArray(e))return R(e);if(e==null)return[];t=re(t);const n=Object.keys(e).sort((o,r)=>r.split("/").length-o.split("/").length).find(o=>t.startsWith(re(o))),a=n?e[n]:[];return Array.isArray(a)?R(a):R(a.items,a.base)}function lt(e){const t=[];let n=0;for(const a in e){const o=e[a];if(o.items){n=t.push(o);continue}t[n]||t.push({items:[]}),t[n].items.push(o)}return t}function ct(e){const t=[];function n(a){for(const o of a)o.text&&o.link&&t.push({text:o.text,link:o.link,docFooterText:o.docFooterText}),o.items&&n(o.items)}return n(e),t}function le(e,t){return Array.isArray(t)?t.some(n=>le(e,n)):z(e,t.link)?!0:t.items?le(e,t.items):!1}function R(e,t){return[...e].map(n=>{const a={...n},o=a.base||t;return o&&a.link&&(a.link=o+a.link),a.items&&(a.items=R(a.items,o)),a})}function D(){const{frontmatter:e,page:t,theme:n}=L(),a=se("(min-width: 960px)"),o=S(!1),r=y(()=>{const w=n.value.sidebar,C=t.value.relativePath;return w?Ne(w,C):[]}),l=S(r.value);F(r,(w,C)=>{JSON.stringify(w)!==JSON.stringify(C)&&(l.value=r.value)});const v=y(()=>e.value.sidebar!==!1&&l.value.length>0&&e.value.layout!=="home"),f=y(()=>$?e.value.aside==null?n.value.aside==="left":e.value.aside==="left":!1),$=y(()=>e.value.layout==="home"?!1:e.value.aside!=null?!!e.value.aside:n.value.aside!==!1),V=y(()=>v.value&&a.value),b=y(()=>v.value?lt(l.value):[]);function P(){o.value=!0}function T(){o.value=!1}function I(){o.value?T():P()}return{isOpen:o,sidebar:l,sidebarGroups:b,hasSidebar:v,hasAside:$,leftAside:f,isSidebarEnabled:V,open:P,close:T,toggle:I}}function ut(e,t){let n;X(()=>{n=e.value?document.activeElement:void 0}),U(()=>{window.addEventListener("keyup",a)}),ve(()=>{window.removeEventListener("keyup",a)});function a(o){o.key==="Escape"&&e.value&&(t(),n==null||n.focus())}}function dt(e){const{page:t,hash:n}=L(),a=S(!1),o=y(()=>e.value.collapsed!=null),r=y(()=>!!e.value.link),l=S(!1),v=()=>{l.value=z(t.value.relativePath,e.value.link)};F([t,e,n],v),U(v);const f=y(()=>l.value?!0:e.value.items?le(t.value.relativePath,e.value.items):!1),$=y(()=>!!(e.value.items&&e.value.items.length));X(()=>{a.value=!!(o.value&&e.value.collapsed)}),fe(()=>{(l.value||f.value)&&(a.value=!1)});function V(){o.value&&(a.value=!a.value)}return{collapsed:a,collapsible:o,isLink:r,isActiveLink:l,hasActiveLink:f,hasChildren:$,toggle:V}}function vt(){const{hasSidebar:e}=D(),t=se("(min-width: 960px)"),n=se("(min-width: 1280px)");return{isAsideEnabled:y(()=>!n.value&&!t.value?!1:e.value?n.value:t.value)}}const ft=/\b(?:VPBadge|header-anchor|footnote-ref|ignore-header)\b/,ce=[];function Me(e){return typeof e.outline=="object"&&!Array.isArray(e.outline)&&e.outline.label||e.outlineTitle||"On this page"}function me(e){const t=[...document.querySelectorAll(".VPDoc :where(h1,h2,h3,h4,h5,h6)")].filter(n=>n.id&&n.hasChildNodes()).map(n=>{const a=Number(n.tagName[1]);return{element:n,title:ht(n),link:"#"+n.id,level:a}});return mt(t,e)}function ht(e){let t="";for(const n of e.childNodes)if(n.nodeType===1){if(ft.test(n.className))continue;t+=n.textContent}else n.nodeType===3&&(t+=n.textContent);return t.trim()}function mt(e,t){if(t===!1)return[];const n=(typeof t=="object"&&!Array.isArray(t)?t.level:t)||2,[a,o]=typeof n=="number"?[n,n]:n==="deep"?[2,6]:n;return _t(e,a,o)}function pt(e,t){const{isAsideEnabled:n}=vt(),a=Qe(r,100);let o=null;U(()=>{requestAnimationFrame(r),window.addEventListener("scroll",a)}),Ee(()=>{l(location.hash)}),ve(()=>{window.removeEventListener("scroll",a)});function r(){if(!n.value)return;const v=window.scrollY,f=window.innerHeight,$=document.body.offsetHeight,V=Math.abs(v+f-$)<1,b=ce.map(({element:T,link:I})=>({link:I,top:kt(T)})).filter(({top:T})=>!Number.isNaN(T)).sort((T,I)=>T.top-I.top);if(!b.length){l(null);return}if(v<1){l(null);return}if(V){l(b[b.length-1].link);return}let P=null;for(const{link:T,top:I}of b){if(I>v+Fe()+4)break;P=T}l(P)}function l(v){o&&o.classList.remove("active"),v==null?o=null:o=e.value.querySelector(`a[href="${decodeURIComponent(v)}"]`);const f=o;f?(f.classList.add("active"),t.value.style.top=f.offsetTop+39+"px",t.value.style.opacity="1"):(t.value.style.top="33px",t.value.style.opacity="0")}}function kt(e){let t=0;for(;e!==document.body;){if(e===null)return NaN;t+=e.offsetTop,e=e.offsetParent}return t}function _t(e,t,n){ce.length=0;const a=[],o=[];return e.forEach(r=>{const l={...r,children:[]};let v=o[o.length-1];for(;v&&v.level>=l.level;)o.pop(),v=o[o.length-1];if(l.element.classList.contains("ignore-header")||v&&"shouldIgnore"in v){o.push({level:l.level,shouldIgnore:!0});return}l.level>n||l.level<t||(ce.push({element:l.element,link:l.link}),v?v.children.push(l):a.push(l),o.push(l))}),a}const bt=["href","title"],gt=p({__name:"VPDocOutlineItem",props:{headers:{},root:{type:Boolean}},setup(e){function t({target:n}){const a=n.href.split("#")[1],o=document.getElementById(decodeURIComponent(a));o==null||o.focus({preventScroll:!0})}return(n,a)=>{const o=W("VPDocOutlineItem",!0);return s(),u("ul",{class:N(["VPDocOutlineItem",e.root?"root":"nested"])},[(s(!0),u(x,null,A(e.headers,({children:r,link:l,title:v})=>(s(),u("li",null,[d("a",{class:"outline-link",href:l,onClick:t,title:v},M(v),9,bt),r!=null&&r.length?(s(),_(o,{key:0,headers:r},null,8,["headers"])):m("",!0)]))),256))],2)}}}),xe=g(gt,[["__scopeId","data-v-b933a997"]]),$t={class:"content"},yt={"aria-level":"2",class:"outline-title",id:"doc-outline-aria-label",role:"heading"},Pt=p({__name:"VPDocAsideOutline",setup(e){const{frontmatter:t,theme:n}=L(),a=$e([]);Y(()=>{a.value=me(t.value.outline??n.value.outline)});const o=S(),r=S();return pt(o,r),(l,v)=>(s(),u("nav",{"aria-labelledby":"doc-outline-aria-label",class:N(["VPDocAsideOutline",{"has-outline":a.value.length>0}]),ref_key:"container",ref:o},[d("div",$t,[d("div",{class:"outline-marker",ref_key:"marker",ref:r},null,512),d("div",yt,M(i(Me)(i(n))),1),k(xe,{headers:a.value,root:!0},null,8,["headers"])])],2))}}),Lt=g(Pt,[["__scopeId","data-v-a5bbad30"]]),Vt={class:"VPDocAsideCarbonAds"},St=p({__name:"VPDocAsideCarbonAds",props:{carbonAds:{}},setup(e){const t=()=>null;return(n,a)=>(s(),u("div",Vt,[k(i(t),{"carbon-ads":e.carbonAds},null,8,["carbon-ads"])]))}}),Tt={class:"VPDocAside"},Nt=p({__name:"VPDocAside",setup(e){const{theme:t}=L();return(n,a)=>(s(),u("div",Tt,[c(n.$slots,"aside-top",{},void 0,!0),c(n.$slots,"aside-outline-before",{},void 0,!0),k(Lt),c(n.$slots,"aside-outline-after",{},void 0,!0),a[0]||(a[0]=d("div",{class:"spacer"},null,-1)),c(n.$slots,"aside-ads-before",{},void 0,!0),i(t).carbonAds?(s(),_(St,{key:0,"carbon-ads":i(t).carbonAds},null,8,["carbon-ads"])):m("",!0),c(n.$slots,"aside-ads-after",{},void 0,!0),c(n.$slots,"aside-bottom",{},void 0,!0)]))}}),Mt=g(Nt,[["__scopeId","data-v-3f215769"]]);function xt(){const{theme:e,page:t}=L();return y(()=>{const{text:n="Edit this page",pattern:a=""}=e.value.editLink||{};let o;return typeof a=="function"?o=a(t.value):o=a.replace(/:path/g,t.value.filePath),{url:o,text:n}})}function It(){const{page:e,theme:t,frontmatter:n}=L();return y(()=>{var $,V,b,P,T,I,w,C;const a=Ne(t.value.sidebar,e.value.relativePath),o=ct(a),r=wt(o,H=>H.link.replace(/[?#].*$/,"")),l=r.findIndex(H=>z(e.value.relativePath,H.link)),v=(($=t.value.docFooter)==null?void 0:$.prev)===!1&&!n.value.prev||n.value.prev===!1,f=((V=t.value.docFooter)==null?void 0:V.next)===!1&&!n.value.next||n.value.next===!1;return{prev:v?void 0:{text:(typeof n.value.prev=="string"?n.value.prev:typeof n.value.prev=="object"?n.value.prev.text:void 0)??((b=r[l-1])==null?void 0:b.docFooterText)??((P=r[l-1])==null?void 0:P.text),link:(typeof n.value.prev=="object"?n.value.prev.link:void 0)??((T=r[l-1])==null?void 0:T.link)},next:f?void 0:{text:(typeof n.value.next=="string"?n.value.next:typeof n.value.next=="object"?n.value.next.text:void 0)??((I=r[l+1])==null?void 0:I.docFooterText)??((w=r[l+1])==null?void 0:w.text),link:(typeof n.value.next=="object"?n.value.next.link:void 0)??((C=r[l+1])==null?void 0:C.link)}}})}function wt(e,t){const n=new Set;return e.filter(a=>{const o=t(a);return n.has(o)?!1:n.add(o)})}const E=p({__name:"VPLink",props:{tag:{},href:{},noIcon:{type:Boolean},target:{},rel:{}},setup(e){const t=e,n=y(()=>t.tag??(t.href?"a":"span")),a=y(()=>t.href&&ye.test(t.href)||t.target==="_blank");return(o,r)=>(s(),_(B(n.value),{class:N(["VPLink",{link:e.href,"vp-external-link-icon":a.value,"no-icon":e.noIcon}]),href:e.href?i(he)(e.href):void 0,target:e.target??(a.value?"_blank":void 0),rel:e.rel??(a.value?"noreferrer":void 0)},{default:h(()=>[c(o.$slots,"default")]),_:3},8,["class","href","target","rel"]))}}),At={class:"VPLastUpdated"},Ct=["datetime"],Ht=p({__name:"VPDocFooterLastUpdated",setup(e){const{theme:t,page:n,lang:a}=L(),o=y(()=>new Date(n.value.lastUpdated)),r=y(()=>o.value.toISOString()),l=S("");return U(()=>{X(()=>{var v,f,$;l.value=new Intl.DateTimeFormat((f=(v=t.value.lastUpdated)==null?void 0:v.formatOptions)!=null&&f.forceLocale?a.value:void 0,(($=t.value.lastUpdated)==null?void 0:$.formatOptions)??{dateStyle:"short",timeStyle:"short"}).format(o.value)})}),(v,f)=>{var $;return s(),u("p",At,[j(M((($=i(t).lastUpdated)==null?void 0:$.text)||i(t).lastUpdatedText||"Last updated")+": ",1),d("time",{datetime:r.value},M(l.value),9,Ct)])}}}),Bt=g(Ht,[["__scopeId","data-v-e98dd255"]]),Et={key:0,class:"VPDocFooter"},Ft={key:0,class:"edit-info"},Dt={key:0,class:"edit-link"},Ot={key:1,class:"last-updated"},Gt={key:1,class:"prev-next","aria-labelledby":"doc-footer-aria-label"},Ut={class:"pager"},jt=["innerHTML"],zt=["innerHTML"],Wt={class:"pager"},Kt=["innerHTML"],qt=["innerHTML"],Rt=p({__name:"VPDocFooter",setup(e){const{theme:t,page:n,frontmatter:a}=L(),o=xt(),r=It(),l=y(()=>t.value.editLink&&a.value.editLink!==!1),v=y(()=>n.value.lastUpdated),f=y(()=>l.value||v.value||r.value.prev||r.value.next);return($,V)=>{var b,P,T,I;return f.value?(s(),u("footer",Et,[c($.$slots,"doc-footer-before",{},void 0,!0),l.value||v.value?(s(),u("div",Ft,[l.value?(s(),u("div",Dt,[k(E,{class:"edit-link-button",href:i(o).url,"no-icon":!0},{default:h(()=>[V[0]||(V[0]=d("span",{class:"vpi-square-pen edit-link-icon"},null,-1)),j(" "+M(i(o).text),1)]),_:1},8,["href"])])):m("",!0),v.value?(s(),u("div",Ot,[k(Bt)])):m("",!0)])):m("",!0),(b=i(r).prev)!=null&&b.link||(P=i(r).next)!=null&&P.link?(s(),u("nav",Gt,[V[1]||(V[1]=d("span",{class:"visually-hidden",id:"doc-footer-aria-label"},"Pager",-1)),d("div",Ut,[(T=i(r).prev)!=null&&T.link?(s(),_(E,{key:0,class:"pager-link prev",href:i(r).prev.link},{default:h(()=>{var w;return[d("span",{class:"desc",innerHTML:((w=i(t).docFooter)==null?void 0:w.prev)||"Previous page"},null,8,jt),d("span",{class:"title",innerHTML:i(r).prev.text},null,8,zt)]}),_:1},8,["href"])):m("",!0)]),d("div",Wt,[(I=i(r).next)!=null&&I.link?(s(),_(E,{key:0,class:"pager-link next",href:i(r).next.link},{default:h(()=>{var w;return[d("span",{class:"desc",innerHTML:((w=i(t).docFooter)==null?void 0:w.next)||"Next page"},null,8,Kt),d("span",{class:"title",innerHTML:i(r).next.text},null,8,qt)]}),_:1},8,["href"])):m("",!0)])])):m("",!0)])):m("",!0)}}}),Jt=g(Rt,[["__scopeId","data-v-e257564d"]]),Xt={class:"container"},Yt={class:"aside-container"},Qt={class:"aside-content"},Zt={class:"content"},en={class:"content-container"},tn={class:"main"},nn=p({__name:"VPDoc",setup(e){const{theme:t}=L(),n=Q(),{hasSidebar:a,hasAside:o,leftAside:r}=D(),l=y(()=>n.path.replace(/[./]+/g,"_").replace(/_html$/,""));return(v,f)=>{const $=W("Content");return s(),u("div",{class:N(["VPDoc",{"has-sidebar":i(a),"has-aside":i(o)}])},[c(v.$slots,"doc-top",{},void 0,!0),d("div",Xt,[i(o)?(s(),u("div",{key:0,class:N(["aside",{"left-aside":i(r)}])},[f[0]||(f[0]=d("div",{class:"aside-curtain"},null,-1)),d("div",Yt,[d("div",Qt,[k(Mt,null,{"aside-top":h(()=>[c(v.$slots,"aside-top",{},void 0,!0)]),"aside-bottom":h(()=>[c(v.$slots,"aside-bottom",{},void 0,!0)]),"aside-outline-before":h(()=>[c(v.$slots,"aside-outline-before",{},void 0,!0)]),"aside-outline-after":h(()=>[c(v.$slots,"aside-outline-after",{},void 0,!0)]),"aside-ads-before":h(()=>[c(v.$slots,"aside-ads-before",{},void 0,!0)]),"aside-ads-after":h(()=>[c(v.$slots,"aside-ads-after",{},void 0,!0)]),_:3})])])],2)):m("",!0),d("div",Zt,[d("div",en,[c(v.$slots,"doc-before",{},void 0,!0),d("main",tn,[k($,{class:N(["vp-doc",[l.value,i(t).externalLinkIcon&&"external-link-icon-enabled"]])},null,8,["class"])]),k(Jt,null,{"doc-footer-before":h(()=>[c(v.$slots,"doc-footer-before",{},void 0,!0)]),_:3}),c(v.$slots,"doc-after",{},void 0,!0)])])]),c(v.$slots,"doc-bottom",{},void 0,!0)],2)}}}),an=g(nn,[["__scopeId","data-v-39a288b8"]]),on=p({__name:"VPButton",props:{tag:{},size:{default:"medium"},theme:{default:"brand"},text:{},href:{},target:{},rel:{}},setup(e){const t=e,n=y(()=>t.href&&ye.test(t.href)),a=y(()=>t.tag||(t.href?"a":"button"));return(o,r)=>(s(),_(B(a.value),{class:N(["VPButton",[e.size,e.theme]]),href:e.href?i(he)(e.href):void 0,target:t.target??(n.value?"_blank":void 0),rel:t.rel??(n.value?"noreferrer":void 0)},{default:h(()=>[j(M(e.text),1)]),_:1},8,["class","href","target","rel"]))}}),sn=g(on,[["__scopeId","data-v-fa7799d5"]]),rn=["src","alt"],ln=p({inheritAttrs:!1,__name:"VPImage",props:{image:{},alt:{}},setup(e){return(t,n)=>{const a=W("VPImage",!0);return e.image?(s(),u(x,{key:0},[typeof e.image=="string"||"src"in e.image?(s(),u("img",G({key:0,class:"VPImage"},typeof e.image=="string"?t.$attrs:{...e.image,...t.$attrs},{src:i(de)(typeof e.image=="string"?e.image:e.image.src),alt:e.alt??(typeof e.image=="string"?"":e.image.alt||"")}),null,16,rn)):(s(),u(x,{key:1},[k(a,G({class:"dark",image:e.image.dark,alt:e.image.alt},t.$attrs),null,16,["image","alt"]),k(a,G({class:"light",image:e.image.light,alt:e.image.alt},t.$attrs),null,16,["image","alt"])],64))],64)):m("",!0)}}}),J=g(ln,[["__scopeId","data-v-8426fc1a"]]),cn={class:"container"},un={class:"main"},dn={class:"heading"},vn=["innerHTML"],fn=["innerHTML"],hn=["innerHTML"],mn={key:0,class:"actions"},pn={key:0,class:"image"},kn={class:"image-container"},_n=p({__name:"VPHero",props:{name:{},text:{},tagline:{},image:{},actions:{}},setup(e){const t=Z("hero-image-slot-exists");return(n,a)=>(s(),u("div",{class:N(["VPHero",{"has-image":e.image||i(t)}])},[d("div",cn,[d("div",un,[c(n.$slots,"home-hero-info-before",{},void 0,!0),c(n.$slots,"home-hero-info",{},()=>[d("h1",dn,[e.name?(s(),u("span",{key:0,innerHTML:e.name,class:"name clip"},null,8,vn)):m("",!0),e.text?(s(),u("span",{key:1,innerHTML:e.text,class:"text"},null,8,fn)):m("",!0)]),e.tagline?(s(),u("p",{key:0,innerHTML:e.tagline,class:"tagline"},null,8,hn)):m("",!0)],!0),c(n.$slots,"home-hero-info-after",{},void 0,!0),e.actions?(s(),u("div",mn,[(s(!0),u(x,null,A(e.actions,o=>(s(),u("div",{key:o.link,class:"action"},[k(sn,{tag:"a",size:"medium",theme:o.theme,text:o.text,href:o.link,target:o.target,rel:o.rel},null,8,["theme","text","href","target","rel"])]))),128))])):m("",!0),c(n.$slots,"home-hero-actions-after",{},void 0,!0)]),e.image||i(t)?(s(),u("div",pn,[d("div",kn,[a[0]||(a[0]=d("div",{class:"image-bg"},null,-1)),c(n.$slots,"home-hero-image",{},()=>[e.image?(s(),_(J,{key:0,class:"image-src",image:e.image},null,8,["image"])):m("",!0)],!0)])])):m("",!0)])],2))}}),bn=g(_n,[["__scopeId","data-v-4f9c455b"]]),gn=p({__name:"VPHomeHero",setup(e){const{frontmatter:t}=L();return(n,a)=>i(t).hero?(s(),_(bn,{key:0,class:"VPHomeHero",name:i(t).hero.name,text:i(t).hero.text,tagline:i(t).hero.tagline,image:i(t).hero.image,actions:i(t).hero.actions},{"home-hero-info-before":h(()=>[c(n.$slots,"home-hero-info-before")]),"home-hero-info":h(()=>[c(n.$slots,"home-hero-info")]),"home-hero-info-after":h(()=>[c(n.$slots,"home-hero-info-after")]),"home-hero-actions-after":h(()=>[c(n.$slots,"home-hero-actions-after")]),"home-hero-image":h(()=>[c(n.$slots,"home-hero-image")]),_:3},8,["name","text","tagline","image","actions"])):m("",!0)}}),$n={class:"box"},yn={key:0,class:"icon"},Pn=["innerHTML"],Ln=["innerHTML"],Vn=["innerHTML"],Sn={key:4,class:"link-text"},Tn={class:"link-text-value"},Nn=p({__name:"VPFeature",props:{icon:{},title:{},details:{},link:{},linkText:{},rel:{},target:{}},setup(e){return(t,n)=>(s(),_(E,{class:"VPFeature",href:e.link,rel:e.rel,target:e.target,"no-icon":!0,tag:e.link?"a":"div"},{default:h(()=>[d("article",$n,[typeof e.icon=="object"&&e.icon.wrap?(s(),u("div",yn,[k(J,{image:e.icon,alt:e.icon.alt,height:e.icon.height||48,width:e.icon.width||48},null,8,["image","alt","height","width"])])):typeof e.icon=="object"?(s(),_(J,{key:1,image:e.icon,alt:e.icon.alt,height:e.icon.height||48,width:e.icon.width||48},null,8,["image","alt","height","width"])):e.icon?(s(),u("div",{key:2,class:"icon",innerHTML:e.icon},null,8,Pn)):m("",!0),d("h2",{class:"title",innerHTML:e.title},null,8,Ln),e.details?(s(),u("p",{key:3,class:"details",innerHTML:e.details},null,8,Vn)):m("",!0),e.linkText?(s(),u("div",Sn,[d("p",Tn,[j(M(e.linkText)+" ",1),n[0]||(n[0]=d("span",{class:"vpi-arrow-right link-text-icon"},null,-1))])])):m("",!0)])]),_:1},8,["href","rel","target","tag"]))}}),Mn=g(Nn,[["__scopeId","data-v-a3976bdc"]]),xn={key:0,class:"VPFeatures"},In={class:"container"},wn={class:"items"},An=p({__name:"VPFeatures",props:{features:{}},setup(e){const t=e,n=y(()=>{const a=t.features.length;if(a){if(a===2)return"grid-2";if(a===3)return"grid-3";if(a%3===0)return"grid-6";if(a>3)return"grid-4"}else return});return(a,o)=>e.features?(s(),u("div",xn,[d("div",In,[d("div",wn,[(s(!0),u(x,null,A(e.features,r=>(s(),u("div",{key:r.title,class:N(["item",[n.value]])},[k(Mn,{icon:r.icon,title:r.title,details:r.details,link:r.link,"link-text":r.linkText,rel:r.rel,target:r.target},null,8,["icon","title","details","link","link-text","rel","target"])],2))),128))])])])):m("",!0)}}),Cn=g(An,[["__scopeId","data-v-a6181336"]]),Hn=p({__name:"VPHomeFeatures",setup(e){const{frontmatter:t}=L();return(n,a)=>i(t).features?(s(),_(Cn,{key:0,class:"VPHomeFeatures",features:i(t).features},null,8,["features"])):m("",!0)}}),Bn=p({__name:"VPHomeContent",setup(e){const{width:t}=De({initialWidth:0,includeScrollbar:!1});return(n,a)=>(s(),u("div",{class:"vp-doc container",style:Pe(i(t)?{"--vp-offset":`calc(50% - ${i(t)/2}px)`}:{})},[c(n.$slots,"default",{},void 0,!0)],4))}}),En=g(Bn,[["__scopeId","data-v-8e2d4988"]]),Fn=p({__name:"VPHome",setup(e){const{frontmatter:t,theme:n}=L();return(a,o)=>{const r=W("Content");return s(),u("div",{class:N(["VPHome",{"external-link-icon-enabled":i(n).externalLinkIcon}])},[c(a.$slots,"home-hero-before",{},void 0,!0),k(gn,null,{"home-hero-info-before":h(()=>[c(a.$slots,"home-hero-info-before",{},void 0,!0)]),"home-hero-info":h(()=>[c(a.$slots,"home-hero-info",{},void 0,!0)]),"home-hero-info-after":h(()=>[c(a.$slots,"home-hero-info-after",{},void 0,!0)]),"home-hero-actions-after":h(()=>[c(a.$slots,"home-hero-actions-after",{},void 0,!0)]),"home-hero-image":h(()=>[c(a.$slots,"home-hero-image",{},void 0,!0)]),_:3}),c(a.$slots,"home-hero-after",{},void 0,!0),c(a.$slots,"home-features-before",{},void 0,!0),k(Hn),c(a.$slots,"home-features-after",{},void 0,!0),i(t).markdownStyles!==!1?(s(),_(En,{key:0},{default:h(()=>[k(r)]),_:1})):(s(),_(r,{key:1}))],2)}}}),Dn=g(Fn,[["__scopeId","data-v-8b561e3d"]]),On={},Gn={class:"VPPage"};function Un(e,t){const n=W("Content");return s(),u("div",Gn,[c(e.$slots,"page-top"),k(n),c(e.$slots,"page-bottom")])}const jn=g(On,[["render",Un]]),zn=p({__name:"VPContent",setup(e){const{page:t,frontmatter:n}=L(),{hasSidebar:a}=D();return(o,r)=>(s(),u("div",{class:N(["VPContent",{"has-sidebar":i(a),"is-home":i(n).layout==="home"}]),id:"VPContent"},[i(t).isNotFound?c(o.$slots,"not-found",{},()=>[k(rt)],!0,0):i(n).layout==="page"?(s(),_(jn,{key:1},{"page-top":h(()=>[c(o.$slots,"page-top",{},void 0,!0)]),"page-bottom":h(()=>[c(o.$slots,"page-bottom",{},void 0,!0)]),_:3})):i(n).layout==="home"?(s(),_(Dn,{key:2},{"home-hero-before":h(()=>[c(o.$slots,"home-hero-before",{},void 0,!0)]),"home-hero-info-before":h(()=>[c(o.$slots,"home-hero-info-before",{},void 0,!0)]),"home-hero-info":h(()=>[c(o.$slots,"home-hero-info",{},void 0,!0)]),"home-hero-info-after":h(()=>[c(o.$slots,"home-hero-info-after",{},void 0,!0)]),"home-hero-actions-after":h(()=>[c(o.$slots,"home-hero-actions-after",{},void 0,!0)]),"home-hero-image":h(()=>[c(o.$slots,"home-hero-image",{},void 0,!0)]),"home-hero-after":h(()=>[c(o.$slots,"home-hero-after",{},void 0,!0)]),"home-features-before":h(()=>[c(o.$slots,"home-features-before",{},void 0,!0)]),"home-features-after":h(()=>[c(o.$slots,"home-features-after",{},void 0,!0)]),_:3})):i(n).layout&&i(n).layout!=="doc"?(s(),_(B(i(n).layout),{key:3})):(s(),_(an,{key:4},{"doc-top":h(()=>[c(o.$slots,"doc-top",{},void 0,!0)]),"doc-bottom":h(()=>[c(o.$slots,"doc-bottom",{},void 0,!0)]),"doc-footer-before":h(()=>[c(o.$slots,"doc-footer-before",{},void 0,!0)]),"doc-before":h(()=>[c(o.$slots,"doc-before",{},void 0,!0)]),"doc-after":h(()=>[c(o.$slots,"doc-after",{},void 0,!0)]),"aside-top":h(()=>[c(o.$slots,"aside-top",{},void 0,!0)]),"aside-outline-before":h(()=>[c(o.$slots,"aside-outline-before",{},void 0,!0)]),"aside-outline-after":h(()=>[c(o.$slots,"aside-outline-after",{},void 0,!0)]),"aside-ads-before":h(()=>[c(o.$slots,"aside-ads-before",{},void 0,!0)]),"aside-ads-after":h(()=>[c(o.$slots,"aside-ads-after",{},void 0,!0)]),"aside-bottom":h(()=>[c(o.$slots,"aside-bottom",{},void 0,!0)]),_:3}))],2))}}),Wn=g(zn,[["__scopeId","data-v-1428d186"]]),Kn={class:"container"},qn=["innerHTML"],Rn=["innerHTML"],Jn=p({__name:"VPFooter",setup(e){const{theme:t,frontmatter:n}=L(),{hasSidebar:a}=D();return(o,r)=>i(t).footer&&i(n).footer!==!1?(s(),u("footer",{key:0,class:N(["VPFooter",{"has-sidebar":i(a)}])},[d("div",Kn,[i(t).footer.message?(s(),u("p",{key:0,class:"message",innerHTML:i(t).footer.message},null,8,qn)):m("",!0),i(t).footer.copyright?(s(),u("p",{key:1,class:"copyright",innerHTML:i(t).footer.copyright},null,8,Rn)):m("",!0)])],2)):m("",!0)}}),Xn=g(Jn,[["__scopeId","data-v-e315a0ad"]]);function Yn(){const{theme:e,frontmatter:t}=L(),n=$e([]),a=y(()=>n.value.length>0);return Y(()=>{n.value=me(t.value.outline??e.value.outline)}),{headers:n,hasLocalNav:a}}const Qn={class:"menu-text"},Zn={class:"header"},ea={class:"outline"},ta=p({__name:"VPLocalNavOutlineDropdown",props:{headers:{},navHeight:{}},setup(e){const t=e,{theme:n}=L(),a=S(!1),o=S(0),r=S(),l=S();function v(b){var P;(P=r.value)!=null&&P.contains(b.target)||(a.value=!1)}F(a,b=>{if(b){document.addEventListener("click",v);return}document.removeEventListener("click",v)}),ie("Escape",()=>{a.value=!1}),Y(()=>{a.value=!1});function f(){a.value=!a.value,o.value=window.innerHeight+Math.min(window.scrollY-t.navHeight,0)}function $(b){b.target.classList.contains("outline-link")&&(l.value&&(l.value.style.transition="none"),Le(()=>{a.value=!1}))}function V(){a.value=!1,window.scrollTo({top:0,left:0,behavior:"smooth"})}return(b,P)=>(s(),u("div",{class:"VPLocalNavOutlineDropdown",style:Pe({"--vp-vh":o.value+"px"}),ref_key:"main",ref:r},[e.headers.length>0?(s(),u("button",{key:0,onClick:f,class:N({open:a.value})},[d("span",Qn,M(i(Me)(i(n))),1),P[0]||(P[0]=d("span",{class:"vpi-chevron-right icon"},null,-1))],2)):(s(),u("button",{key:1,onClick:V},M(i(n).returnToTopLabel||"Return to top"),1)),k(ue,{name:"flyout"},{default:h(()=>[a.value?(s(),u("div",{key:0,ref_key:"items",ref:l,class:"items",onClick:$},[d("div",Zn,[d("a",{class:"top-link",href:"#",onClick:V},M(i(n).returnToTopLabel||"Return to top"),1)]),d("div",ea,[k(xe,{headers:e.headers},null,8,["headers"])])],512)):m("",!0)]),_:1})],4))}}),na=g(ta,[["__scopeId","data-v-8a42e2b4"]]),aa={class:"container"},oa=["aria-expanded"],sa={class:"menu-text"},ia=p({__name:"VPLocalNav",props:{open:{type:Boolean}},emits:["open-menu"],setup(e){const{theme:t,frontmatter:n}=L(),{hasSidebar:a}=D(),{headers:o}=Yn(),{y:r}=Ve(),l=S(0);U(()=>{l.value=parseInt(getComputedStyle(document.documentElement).getPropertyValue("--vp-nav-height"))}),Y(()=>{o.value=me(n.value.outline??t.value.outline)});const v=y(()=>o.value.length===0),f=y(()=>v.value&&!a.value),$=y(()=>({VPLocalNav:!0,"has-sidebar":a.value,empty:v.value,fixed:f.value}));return(V,b)=>i(n).layout!=="home"&&(!f.value||i(r)>=l.value)?(s(),u("div",{key:0,class:N($.value)},[d("div",aa,[i(a)?(s(),u("button",{key:0,class:"menu","aria-expanded":e.open,"aria-controls":"VPSidebarNav",onClick:b[0]||(b[0]=P=>V.$emit("open-menu"))},[b[1]||(b[1]=d("span",{class:"vpi-align-left menu-icon"},null,-1)),d("span",sa,M(i(t).sidebarMenuLabel||"Menu"),1)],8,oa)):m("",!0),k(na,{headers:i(o),navHeight:l.value},null,8,["headers","navHeight"])])],2)):m("",!0)}}),ra=g(ia,[["__scopeId","data-v-a6f0e41e"]]);function la(){const e=S(!1);function t(){e.value=!0,window.addEventListener("resize",o)}function n(){e.value=!1,window.removeEventListener("resize",o)}function a(){e.value?n():t()}function o(){window.outerWidth>=768&&n()}const r=Q();return F(()=>r.path,n),{isScreenOpen:e,openScreen:t,closeScreen:n,toggleScreen:a}}const ca={},ua={class:"VPSwitch",type:"button",role:"switch"},da={class:"check"},va={key:0,class:"icon"};function fa(e,t){return s(),u("button",ua,[d("span",da,[e.$slots.default?(s(),u("span",va,[c(e.$slots,"default",{},void 0,!0)])):m("",!0)])])}const ha=g(ca,[["render",fa],["__scopeId","data-v-1d5665e3"]]),ma=p({__name:"VPSwitchAppearance",setup(e){const{isDark:t,theme:n}=L(),a=Z("toggle-appearance",()=>{t.value=!t.value}),o=S("");return fe(()=>{o.value=t.value?n.value.lightModeSwitchTitle||"Switch to light theme":n.value.darkModeSwitchTitle||"Switch to dark theme"}),(r,l)=>(s(),_(ha,{title:o.value,class:"VPSwitchAppearance","aria-checked":i(t),onClick:i(a)},{default:h(()=>[...l[0]||(l[0]=[d("span",{class:"vpi-sun sun"},null,-1),d("span",{class:"vpi-moon moon"},null,-1)])]),_:1},8,["title","aria-checked","onClick"]))}}),pe=g(ma,[["__scopeId","data-v-5337faa4"]]),pa={key:0,class:"VPNavBarAppearance"},ka=p({__name:"VPNavBarAppearance",setup(e){const{site:t}=L();return(n,a)=>i(t).appearance&&i(t).appearance!=="force-dark"&&i(t).appearance!=="force-auto"?(s(),u("div",pa,[k(pe)])):m("",!0)}}),_a=g(ka,[["__scopeId","data-v-6c893767"]]),ke=S();let Ie=!1,oe=0;function ba(e){const t=S(!1);if(ee){!Ie&&ga(),oe++;const n=F(ke,a=>{var o,r,l;a===e.el.value||(o=e.el.value)!=null&&o.contains(a)?(t.value=!0,(r=e.onFocus)==null||r.call(e)):(t.value=!1,(l=e.onBlur)==null||l.call(e))});ve(()=>{n(),oe--,oe||$a()})}return Oe(t)}function ga(){document.addEventListener("focusin",we),Ie=!0,ke.value=document.activeElement}function $a(){document.removeEventListener("focusin",we)}function we(){ke.value=document.activeElement}const ya={class:"VPMenuLink"},Pa=["innerHTML"],La=p({__name:"VPMenuLink",props:{item:{}},setup(e){const{page:t}=L();return(n,a)=>(s(),u("div",ya,[k(E,{class:N({active:i(z)(i(t).relativePath,e.item.activeMatch||e.item.link,!!e.item.activeMatch)}),href:e.item.link,target:e.item.target,rel:e.item.rel,"no-icon":e.item.noIcon},{default:h(()=>[d("span",{innerHTML:e.item.text},null,8,Pa)]),_:1},8,["class","href","target","rel","no-icon"])]))}}),te=g(La,[["__scopeId","data-v-35975db6"]]),Va={class:"VPMenuGroup"},Sa={key:0,class:"title"},Ta=p({__name:"VPMenuGroup",props:{text:{},items:{}},setup(e){return(t,n)=>(s(),u("div",Va,[e.text?(s(),u("p",Sa,M(e.text),1)):m("",!0),(s(!0),u(x,null,A(e.items,a=>(s(),u(x,null,["link"in a?(s(),_(te,{key:0,item:a},null,8,["item"])):m("",!0)],64))),256))]))}}),Na=g(Ta,[["__scopeId","data-v-69e747b5"]]),Ma={class:"VPMenu"},xa={key:0,class:"items"},Ia=p({__name:"VPMenu",props:{items:{}},setup(e){return(t,n)=>(s(),u("div",Ma,[e.items?(s(),u("div",xa,[(s(!0),u(x,null,A(e.items,a=>(s(),u(x,{key:JSON.stringify(a)},["link"in a?(s(),_(te,{key:0,item:a},null,8,["item"])):"component"in a?(s(),_(B(a.component),G({key:1,ref_for:!0},a.props),null,16)):(s(),_(Na,{key:2,text:a.text,items:a.items},null,8,["text","items"]))],64))),128))])):m("",!0),c(t.$slots,"default",{},void 0,!0)]))}}),wa=g(Ia,[["__scopeId","data-v-b98bc113"]]),Aa=["aria-expanded","aria-label"],Ca={key:0,class:"text"},Ha=["innerHTML"],Ba={key:1,class:"vpi-more-horizontal icon"},Ea={class:"menu"},Fa=p({__name:"VPFlyout",props:{icon:{},button:{},label:{},items:{}},setup(e){const t=S(!1),n=S();ba({el:n,onBlur:a});function a(){t.value=!1}return(o,r)=>(s(),u("div",{class:"VPFlyout",ref_key:"el",ref:n,onMouseenter:r[1]||(r[1]=l=>t.value=!0),onMouseleave:r[2]||(r[2]=l=>t.value=!1)},[d("button",{type:"button",class:"button","aria-haspopup":"true","aria-expanded":t.value,"aria-label":e.label,onClick:r[0]||(r[0]=l=>t.value=!t.value)},[e.button||e.icon?(s(),u("span",Ca,[e.icon?(s(),u("span",{key:0,class:N([e.icon,"option-icon"])},null,2)):m("",!0),e.button?(s(),u("span",{key:1,innerHTML:e.button},null,8,Ha)):m("",!0),r[3]||(r[3]=d("span",{class:"vpi-chevron-down text-icon"},null,-1))])):(s(),u("span",Ba))],8,Aa),d("div",Ea,[k(wa,{items:e.items},{default:h(()=>[c(o.$slots,"default",{},void 0,!0)]),_:3},8,["items"])])],544))}}),_e=g(Fa,[["__scopeId","data-v-cf11d7a2"]]),Da=["href","aria-label","innerHTML"],Oa=p({__name:"VPSocialLink",props:{icon:{},link:{},ariaLabel:{}},setup(e){const t=e,n=S();U(async()=>{var r;await Le();const o=(r=n.value)==null?void 0:r.children[0];o instanceof HTMLElement&&o.className.startsWith("vpi-social-")&&(getComputedStyle(o).maskImage||getComputedStyle(o).webkitMaskImage)==="none"&&o.style.setProperty("--icon",`url('https://api.iconify.design/simple-icons/${t.icon}.svg')`)});const a=y(()=>typeof t.icon=="object"?t.icon.svg:`<span class="vpi-social-${t.icon}"></span>`);return(o,r)=>(s(),u("a",{ref_key:"el",ref:n,class:"VPSocialLink no-icon",href:e.link,"aria-label":e.ariaLabel??(typeof e.icon=="string"?e.icon:""),target:"_blank",rel:"noopener",innerHTML:a.value},null,8,Da))}}),Ga=g(Oa,[["__scopeId","data-v-bd121fe5"]]),Ua={class:"VPSocialLinks"},ja=p({__name:"VPSocialLinks",props:{links:{}},setup(e){return(t,n)=>(s(),u("div",Ua,[(s(!0),u(x,null,A(e.links,({link:a,icon:o,ariaLabel:r})=>(s(),_(Ga,{key:a,icon:o,link:a,ariaLabel:r},null,8,["icon","link","ariaLabel"]))),128))]))}}),be=g(ja,[["__scopeId","data-v-7bc22406"]]),za={key:0,class:"group translations"},Wa={class:"trans-title"},Ka={key:1,class:"group"},qa={class:"item appearance"},Ra={class:"label"},Ja={class:"appearance-action"},Xa={key:2,class:"group"},Ya={class:"item social-links"},Qa=p({__name:"VPNavBarExtra",setup(e){const{site:t,theme:n}=L(),{localeLinks:a,currentLang:o}=q({correspondingLink:!0}),r=y(()=>a.value.length&&o.value.label||t.value.appearance||n.value.socialLinks);return(l,v)=>r.value?(s(),_(_e,{key:0,class:"VPNavBarExtra",label:"extra navigation"},{default:h(()=>[i(a).length&&i(o).label?(s(),u("div",za,[d("p",Wa,M(i(o).label),1),(s(!0),u(x,null,A(i(a),f=>(s(),_(te,{key:f.link,item:f},null,8,["item"]))),128))])):m("",!0),i(t).appearance&&i(t).appearance!=="force-dark"&&i(t).appearance!=="force-auto"?(s(),u("div",Ka,[d("div",qa,[d("p",Ra,M(i(n).darkModeSwitchLabel||"Appearance"),1),d("div",Ja,[k(pe)])])])):m("",!0),i(n).socialLinks?(s(),u("div",Xa,[d("div",Ya,[k(be,{class:"social-links-list",links:i(n).socialLinks},null,8,["links"])])])):m("",!0)]),_:1})):m("",!0)}}),Za=g(Qa,[["__scopeId","data-v-bb2aa2f0"]]),eo=["aria-expanded"],to=p({__name:"VPNavBarHamburger",props:{active:{type:Boolean}},emits:["click"],setup(e){return(t,n)=>(s(),u("button",{type:"button",class:N(["VPNavBarHamburger",{active:e.active}]),"aria-label":"mobile navigation","aria-expanded":e.active,"aria-controls":"VPNavScreen",onClick:n[0]||(n[0]=a=>t.$emit("click"))},[...n[1]||(n[1]=[d("span",{class:"container"},[d("span",{class:"top"}),d("span",{class:"middle"}),d("span",{class:"bottom"})],-1)])],10,eo))}}),no=g(to,[["__scopeId","data-v-e5dd9c1c"]]),ao=["innerHTML"],oo=p({__name:"VPNavBarMenuLink",props:{item:{}},setup(e){const{page:t}=L();return(n,a)=>(s(),_(E,{class:N({VPNavBarMenuLink:!0,active:i(z)(i(t).relativePath,e.item.activeMatch||e.item.link,!!e.item.activeMatch)}),href:e.item.link,target:e.item.target,rel:e.item.rel,"no-icon":e.item.noIcon,tabindex:"0"},{default:h(()=>[d("span",{innerHTML:e.item.text},null,8,ao)]),_:1},8,["class","href","target","rel","no-icon"]))}}),so=g(oo,[["__scopeId","data-v-e56f3d57"]]),io=p({__name:"VPNavBarMenuGroup",props:{item:{}},setup(e){const t=e,{page:n}=L(),a=r=>"component"in r?!1:"link"in r?z(n.value.relativePath,r.link,!!t.item.activeMatch):r.items.some(a),o=y(()=>a(t.item));return(r,l)=>(s(),_(_e,{class:N({VPNavBarMenuGroup:!0,active:i(z)(i(n).relativePath,e.item.activeMatch,!!e.item.activeMatch)||o.value}),button:e.item.text,items:e.item.items},null,8,["class","button","items"]))}}),ro={key:0,"aria-labelledby":"main-nav-aria-label",class:"VPNavBarMenu"},lo=p({__name:"VPNavBarMenu",setup(e){const{theme:t}=L();return(n,a)=>i(t).nav?(s(),u("nav",ro,[a[0]||(a[0]=d("span",{id:"main-nav-aria-label",class:"visually-hidden"}," Main Navigation ",-1)),(s(!0),u(x,null,A(i(t).nav,o=>(s(),u(x,{key:JSON.stringify(o)},["link"in o?(s(),_(so,{key:0,item:o},null,8,["item"])):"component"in o?(s(),_(B(o.component),G({key:1,ref_for:!0},o.props),null,16)):(s(),_(io,{key:2,item:o},null,8,["item"]))],64))),128))])):m("",!0)}}),co=g(lo,[["__scopeId","data-v-dc692963"]]);function uo(e){const{localeIndex:t,theme:n}=L();function a(o){var I,w,C;const r=o.split("."),l=(I=n.value.search)==null?void 0:I.options,v=l&&typeof l=="object",f=v&&((C=(w=l.locales)==null?void 0:w[t.value])==null?void 0:C.translations)||null,$=v&&l.translations||null;let V=f,b=$,P=e;const T=r.pop();for(const H of r){let O=null;const K=P==null?void 0:P[H];K&&(O=P=K);const ne=b==null?void 0:b[H];ne&&(O=b=ne);const ae=V==null?void 0:V[H];ae&&(O=V=ae),K||(P=O),ne||(b=O),ae||(V=O)}return(V==null?void 0:V[T])??(b==null?void 0:b[T])??(P==null?void 0:P[T])??""}return a}const vo=["aria-label"],fo={class:"DocSearch-Button-Container"},ho={class:"DocSearch-Button-Placeholder"},ge=p({__name:"VPNavBarSearchButton",setup(e){const n=uo({button:{buttonText:"Search",buttonAriaLabel:"Search"}});return(a,o)=>(s(),u("button",{type:"button",class:"DocSearch DocSearch-Button","aria-label":i(n)("button.buttonAriaLabel")},[d("span",fo,[o[0]||(o[0]=d("span",{class:"vp-icon DocSearch-Search-Icon"},null,-1)),d("span",ho,M(i(n)("button.buttonText")),1)]),o[1]||(o[1]=d("span",{class:"DocSearch-Button-Keys"},[d("kbd",{class:"DocSearch-Button-Key"}),d("kbd",{class:"DocSearch-Button-Key"},"K")],-1))],8,vo))}}),mo={class:"VPNavBarSearch"},po={id:"local-search"},ko={key:1,id:"docsearch"},_o=p({__name:"VPNavBarSearch",setup(e){const t=Ge(()=>Ue(()=>import("./VPLocalSearchBox.L5jNOIR3.js"),__vite__mapDeps([0,1]))),n=()=>null,{theme:a}=L(),o=S(!1),r=S(!1);U(()=>{});function l(){o.value||(o.value=!0,setTimeout(v,16))}function v(){const b=new Event("keydown");b.key="k",b.metaKey=!0,window.dispatchEvent(b),setTimeout(()=>{document.querySelector(".DocSearch-Modal")||v()},16)}function f(b){const P=b.target,T=P.tagName;return P.isContentEditable||T==="INPUT"||T==="SELECT"||T==="TEXTAREA"}const $=S(!1);ie("k",b=>{(b.ctrlKey||b.metaKey)&&(b.preventDefault(),$.value=!0)}),ie("/",b=>{f(b)||(b.preventDefault(),$.value=!0)});const V="local";return(b,P)=>{var T;return s(),u("div",mo,[i(V)==="local"?(s(),u(x,{key:0},[$.value?(s(),_(i(t),{key:0,onClose:P[0]||(P[0]=I=>$.value=!1)})):m("",!0),d("div",po,[k(ge,{onClick:P[1]||(P[1]=I=>$.value=!0)})])],64)):i(V)==="algolia"?(s(),u(x,{key:1},[o.value?(s(),_(i(n),{key:0,algolia:((T=i(a).search)==null?void 0:T.options)??i(a).algolia,onVnodeBeforeMount:P[2]||(P[2]=I=>r.value=!0)},null,8,["algolia"])):m("",!0),r.value?m("",!0):(s(),u("div",ko,[k(ge,{onClick:l})]))],64)):m("",!0)])}}}),bo=p({__name:"VPNavBarSocialLinks",setup(e){const{theme:t}=L();return(n,a)=>i(t).socialLinks?(s(),_(be,{key:0,class:"VPNavBarSocialLinks",links:i(t).socialLinks},null,8,["links"])):m("",!0)}}),go=g(bo,[["__scopeId","data-v-0394ad82"]]),$o=["href","rel","target"],yo=["innerHTML"],Po={key:2},Lo=p({__name:"VPNavBarTitle",setup(e){const{site:t,theme:n}=L(),{hasSidebar:a}=D(),{currentLang:o}=q(),r=y(()=>{var f;return typeof n.value.logoLink=="string"?n.value.logoLink:(f=n.value.logoLink)==null?void 0:f.link}),l=y(()=>{var f;return typeof n.value.logoLink=="string"||(f=n.value.logoLink)==null?void 0:f.rel}),v=y(()=>{var f;return typeof n.value.logoLink=="string"||(f=n.value.logoLink)==null?void 0:f.target});return(f,$)=>(s(),u("div",{class:N(["VPNavBarTitle",{"has-sidebar":i(a)}])},[d("a",{class:"title",href:r.value??i(he)(i(o).link),rel:l.value,target:v.value},[c(f.$slots,"nav-bar-title-before",{},void 0,!0),i(n).logo?(s(),_(J,{key:0,class:"logo",image:i(n).logo},null,8,["image"])):m("",!0),i(n).siteTitle?(s(),u("span",{key:1,innerHTML:i(n).siteTitle},null,8,yo)):i(n).siteTitle===void 0?(s(),u("span",Po,M(i(t).title),1)):m("",!0),c(f.$slots,"nav-bar-title-after",{},void 0,!0)],8,$o)],2))}}),Vo=g(Lo,[["__scopeId","data-v-1168a8e4"]]),So={class:"items"},To={class:"title"},No=p({__name:"VPNavBarTranslations",setup(e){const{theme:t}=L(),{localeLinks:n,currentLang:a}=q({correspondingLink:!0});return(o,r)=>i(n).length&&i(a).label?(s(),_(_e,{key:0,class:"VPNavBarTranslations",icon:"vpi-languages",label:i(t).langMenuLabel||"Change language"},{default:h(()=>[d("div",So,[d("p",To,M(i(a).label),1),(s(!0),u(x,null,A(i(n),l=>(s(),_(te,{key:l.link,item:l},null,8,["item"]))),128))])]),_:1},8,["label"])):m("",!0)}}),Mo=g(No,[["__scopeId","data-v-88af2de4"]]),xo={class:"wrapper"},Io={class:"container"},wo={class:"title"},Ao={class:"content"},Co={class:"content-body"},Ho=p({__name:"VPNavBar",props:{isScreenOpen:{type:Boolean}},emits:["toggle-screen"],setup(e){const t=e,{y:n}=Ve(),{hasSidebar:a}=D(),{frontmatter:o}=L(),r=S({});return fe(()=>{r.value={"has-sidebar":a.value,home:o.value.layout==="home",top:n.value===0,"screen-open":t.isScreenOpen}}),(l,v)=>(s(),u("div",{class:N(["VPNavBar",r.value])},[d("div",xo,[d("div",Io,[d("div",wo,[k(Vo,null,{"nav-bar-title-before":h(()=>[c(l.$slots,"nav-bar-title-before",{},void 0,!0)]),"nav-bar-title-after":h(()=>[c(l.$slots,"nav-bar-title-after",{},void 0,!0)]),_:3})]),d("div",Ao,[d("div",Co,[c(l.$slots,"nav-bar-content-before",{},void 0,!0),k(_o,{class:"search"}),k(co,{class:"menu"}),k(Mo,{class:"translations"}),k(_a,{class:"appearance"}),k(go,{class:"social-links"}),k(Za,{class:"extra"}),c(l.$slots,"nav-bar-content-after",{},void 0,!0),k(no,{class:"hamburger",active:e.isScreenOpen,onClick:v[0]||(v[0]=f=>l.$emit("toggle-screen"))},null,8,["active"])])])])]),v[1]||(v[1]=d("div",{class:"divider"},[d("div",{class:"divider-line"})],-1))],2))}}),Bo=g(Ho,[["__scopeId","data-v-6aa21345"]]),Eo={key:0,class:"VPNavScreenAppearance"},Fo={class:"text"},Do=p({__name:"VPNavScreenAppearance",setup(e){const{site:t,theme:n}=L();return(a,o)=>i(t).appearance&&i(t).appearance!=="force-dark"&&i(t).appearance!=="force-auto"?(s(),u("div",Eo,[d("p",Fo,M(i(n).darkModeSwitchLabel||"Appearance"),1),k(pe)])):m("",!0)}}),Oo=g(Do,[["__scopeId","data-v-b44890b2"]]),Go=["innerHTML"],Uo=p({__name:"VPNavScreenMenuLink",props:{item:{}},setup(e){const t=Z("close-screen");return(n,a)=>(s(),_(E,{class:"VPNavScreenMenuLink",href:e.item.link,target:e.item.target,rel:e.item.rel,"no-icon":e.item.noIcon,onClick:i(t)},{default:h(()=>[d("span",{innerHTML:e.item.text},null,8,Go)]),_:1},8,["href","target","rel","no-icon","onClick"]))}}),jo=g(Uo,[["__scopeId","data-v-df37e6dd"]]),zo=["innerHTML"],Wo=p({__name:"VPNavScreenMenuGroupLink",props:{item:{}},setup(e){const t=Z("close-screen");return(n,a)=>(s(),_(E,{class:"VPNavScreenMenuGroupLink",href:e.item.link,target:e.item.target,rel:e.item.rel,"no-icon":e.item.noIcon,onClick:i(t)},{default:h(()=>[d("span",{innerHTML:e.item.text},null,8,zo)]),_:1},8,["href","target","rel","no-icon","onClick"]))}}),Ae=g(Wo,[["__scopeId","data-v-3e9c20e4"]]),Ko={class:"VPNavScreenMenuGroupSection"},qo={key:0,class:"title"},Ro=p({__name:"VPNavScreenMenuGroupSection",props:{text:{},items:{}},setup(e){return(t,n)=>(s(),u("div",Ko,[e.text?(s(),u("p",qo,M(e.text),1)):m("",!0),(s(!0),u(x,null,A(e.items,a=>(s(),_(Ae,{key:a.text,item:a},null,8,["item"]))),128))]))}}),Jo=g(Ro,[["__scopeId","data-v-8133b170"]]),Xo=["aria-controls","aria-expanded"],Yo=["innerHTML"],Qo=["id"],Zo={key:0,class:"item"},es={key:1,class:"item"},ts={key:2,class:"group"},ns=p({__name:"VPNavScreenMenuGroup",props:{text:{},items:{}},setup(e){const t=e,n=S(!1),a=y(()=>`NavScreenGroup-${t.text.replace(" ","-").toLowerCase()}`);function o(){n.value=!n.value}return(r,l)=>(s(),u("div",{class:N(["VPNavScreenMenuGroup",{open:n.value}])},[d("button",{class:"button","aria-controls":a.value,"aria-expanded":n.value,onClick:o},[d("span",{class:"button-text",innerHTML:e.text},null,8,Yo),l[0]||(l[0]=d("span",{class:"vpi-plus button-icon"},null,-1))],8,Xo),d("div",{id:a.value,class:"items"},[(s(!0),u(x,null,A(e.items,v=>(s(),u(x,{key:JSON.stringify(v)},["link"in v?(s(),u("div",Zo,[k(Ae,{item:v},null,8,["item"])])):"component"in v?(s(),u("div",es,[(s(),_(B(v.component),G({ref_for:!0},v.props,{"screen-menu":""}),null,16))])):(s(),u("div",ts,[k(Jo,{text:v.text,items:v.items},null,8,["text","items"])]))],64))),128))],8,Qo)],2))}}),as=g(ns,[["__scopeId","data-v-b9ab8c58"]]),os={key:0,class:"VPNavScreenMenu"},ss=p({__name:"VPNavScreenMenu",setup(e){const{theme:t}=L();return(n,a)=>i(t).nav?(s(),u("nav",os,[(s(!0),u(x,null,A(i(t).nav,o=>(s(),u(x,{key:JSON.stringify(o)},["link"in o?(s(),_(jo,{key:0,item:o},null,8,["item"])):"component"in o?(s(),_(B(o.component),G({key:1,ref_for:!0},o.props,{"screen-menu":""}),null,16)):(s(),_(as,{key:2,text:o.text||"",items:o.items},null,8,["text","items"]))],64))),128))])):m("",!0)}}),is=p({__name:"VPNavScreenSocialLinks",setup(e){const{theme:t}=L();return(n,a)=>i(t).socialLinks?(s(),_(be,{key:0,class:"VPNavScreenSocialLinks",links:i(t).socialLinks},null,8,["links"])):m("",!0)}}),rs={class:"list"},ls=p({__name:"VPNavScreenTranslations",setup(e){const{localeLinks:t,currentLang:n}=q({correspondingLink:!0}),a=S(!1);function o(){a.value=!a.value}return(r,l)=>i(t).length&&i(n).label?(s(),u("div",{key:0,class:N(["VPNavScreenTranslations",{open:a.value}])},[d("button",{class:"title",onClick:o},[l[0]||(l[0]=d("span",{class:"vpi-languages icon lang"},null,-1)),j(" "+M(i(n).label)+" ",1),l[1]||(l[1]=d("span",{class:"vpi-chevron-down icon chevron"},null,-1))]),d("ul",rs,[(s(!0),u(x,null,A(i(t),v=>(s(),u("li",{key:v.link,class:"item"},[k(E,{class:"link",href:v.link},{default:h(()=>[j(M(v.text),1)]),_:2},1032,["href"])]))),128))])],2)):m("",!0)}}),cs=g(ls,[["__scopeId","data-v-858fe1a4"]]),us={class:"container"},ds=p({__name:"VPNavScreen",props:{open:{type:Boolean}},setup(e){const t=S(null),n=Se(ee?document.body:null);return(a,o)=>(s(),_(ue,{name:"fade",onEnter:o[0]||(o[0]=r=>n.value=!0),onAfterLeave:o[1]||(o[1]=r=>n.value=!1)},{default:h(()=>[e.open?(s(),u("div",{key:0,class:"VPNavScreen",ref_key:"screen",ref:t,id:"VPNavScreen"},[d("div",us,[c(a.$slots,"nav-screen-content-before",{},void 0,!0),k(ss,{class:"menu"}),k(cs,{class:"translations"}),k(Oo,{class:"appearance"}),k(is,{class:"social-links"}),c(a.$slots,"nav-screen-content-after",{},void 0,!0)])],512)):m("",!0)]),_:3}))}}),vs=g(ds,[["__scopeId","data-v-f2779853"]]),fs={key:0,class:"VPNav"},hs=p({__name:"VPNav",setup(e){const{isScreenOpen:t,closeScreen:n,toggleScreen:a}=la(),{frontmatter:o}=L(),r=y(()=>o.value.navbar!==!1);return Te("close-screen",n),X(()=>{ee&&document.documentElement.classList.toggle("hide-nav",!r.value)}),(l,v)=>r.value?(s(),u("header",fs,[k(Bo,{"is-screen-open":i(t),onToggleScreen:i(a)},{"nav-bar-title-before":h(()=>[c(l.$slots,"nav-bar-title-before",{},void 0,!0)]),"nav-bar-title-after":h(()=>[c(l.$slots,"nav-bar-title-after",{},void 0,!0)]),"nav-bar-content-before":h(()=>[c(l.$slots,"nav-bar-content-before",{},void 0,!0)]),"nav-bar-content-after":h(()=>[c(l.$slots,"nav-bar-content-after",{},void 0,!0)]),_:3},8,["is-screen-open","onToggleScreen"]),k(vs,{open:i(t)},{"nav-screen-content-before":h(()=>[c(l.$slots,"nav-screen-content-before",{},void 0,!0)]),"nav-screen-content-after":h(()=>[c(l.$slots,"nav-screen-content-after",{},void 0,!0)]),_:3},8,["open"])])):m("",!0)}}),ms=g(hs,[["__scopeId","data-v-ae24b3ad"]]),ps=["role","tabindex"],ks={key:1,class:"items"},_s=p({__name:"VPSidebarItem",props:{item:{},depth:{}},setup(e){const t=e,{collapsed:n,collapsible:a,isLink:o,isActiveLink:r,hasActiveLink:l,hasChildren:v,toggle:f}=dt(y(()=>t.item)),$=y(()=>v.value?"section":"div"),V=y(()=>o.value?"a":"div"),b=y(()=>v.value?t.depth+2===7?"p":`h${t.depth+2}`:"p"),P=y(()=>o.value?void 0:"button"),T=y(()=>[[`level-${t.depth}`],{collapsible:a.value},{collapsed:n.value},{"is-link":o.value},{"is-active":r.value},{"has-active":l.value}]);function I(C){"key"in C&&C.key!=="Enter"||!t.item.link&&f()}function w(){t.item.link&&f()}return(C,H)=>{const O=W("VPSidebarItem",!0);return s(),_(B($.value),{class:N(["VPSidebarItem",T.value])},{default:h(()=>[e.item.text?(s(),u("div",G({key:0,class:"item",role:P.value},je(e.item.items?{click:I,keydown:I}:{},!0),{tabindex:e.item.items&&0}),[H[1]||(H[1]=d("div",{class:"indicator"},null,-1)),e.item.link?(s(),_(E,{key:0,tag:V.value,class:"link",href:e.item.link,rel:e.item.rel,target:e.item.target},{default:h(()=>[(s(),_(B(b.value),{class:"text",innerHTML:e.item.text},null,8,["innerHTML"]))]),_:1},8,["tag","href","rel","target"])):(s(),_(B(b.value),{key:1,class:"text",innerHTML:e.item.text},null,8,["innerHTML"])),e.item.collapsed!=null&&e.item.items&&e.item.items.length?(s(),u("div",{key:2,class:"caret",role:"button","aria-label":"toggle section",onClick:w,onKeydown:ze(w,["enter"]),tabindex:"0"},[...H[0]||(H[0]=[d("span",{class:"vpi-chevron-right caret-icon"},null,-1)])],32)):m("",!0)],16,ps)):m("",!0),e.item.items&&e.item.items.length?(s(),u("div",ks,[e.depth<5?(s(!0),u(x,{key:0},A(e.item.items,K=>(s(),_(O,{key:K.text,item:K,depth:e.depth+1},null,8,["item","depth"]))),128)):m("",!0)])):m("",!0)]),_:1},8,["class"])}}}),bs=g(_s,[["__scopeId","data-v-b3fd67f8"]]),gs=p({__name:"VPSidebarGroup",props:{items:{}},setup(e){const t=S(!0);let n=null;return U(()=>{n=setTimeout(()=>{n=null,t.value=!1},300)}),We(()=>{n!=null&&(clearTimeout(n),n=null)}),(a,o)=>(s(!0),u(x,null,A(e.items,r=>(s(),u("div",{key:r.text,class:N(["group",{"no-transition":t.value}])},[k(bs,{item:r,depth:0},null,8,["item"])],2))),128))}}),$s=g(gs,[["__scopeId","data-v-c40bc020"]]),ys={class:"nav",id:"VPSidebarNav","aria-labelledby":"sidebar-aria-label",tabindex:"-1"},Ps=p({__name:"VPSidebar",props:{open:{type:Boolean}},setup(e){const{sidebarGroups:t,hasSidebar:n}=D(),a=e,o=S(null),r=Se(ee?document.body:null);F([a,o],()=>{var v;a.open?(r.value=!0,(v=o.value)==null||v.focus()):r.value=!1},{immediate:!0,flush:"post"});const l=S(0);return F(t,()=>{l.value+=1},{deep:!0}),(v,f)=>i(n)?(s(),u("aside",{key:0,class:N(["VPSidebar",{open:e.open}]),ref_key:"navEl",ref:o,onClick:f[0]||(f[0]=Ke(()=>{},["stop"]))},[f[2]||(f[2]=d("div",{class:"curtain"},null,-1)),d("nav",ys,[f[1]||(f[1]=d("span",{class:"visually-hidden",id:"sidebar-aria-label"}," Sidebar Navigation ",-1)),c(v.$slots,"sidebar-nav-before",{},void 0,!0),(s(),_($s,{items:i(t),key:l.value},null,8,["items"])),c(v.$slots,"sidebar-nav-after",{},void 0,!0)])],2)):m("",!0)}}),Ls=g(Ps,[["__scopeId","data-v-319d5ca6"]]),Vs=p({__name:"VPSkipLink",setup(e){const{theme:t}=L(),n=Q(),a=S();F(()=>n.path,()=>a.value.focus());function o({target:r}){const l=document.getElementById(decodeURIComponent(r.hash).slice(1));if(l){const v=()=>{l.removeAttribute("tabindex"),l.removeEventListener("blur",v)};l.setAttribute("tabindex","-1"),l.addEventListener("blur",v),l.focus(),window.scrollTo(0,0)}}return(r,l)=>(s(),u(x,null,[d("span",{ref_key:"backToTop",ref:a,tabindex:"-1"},null,512),d("a",{href:"#VPContent",class:"VPSkipLink visually-hidden",onClick:o},M(i(t).skipToContentLabel||"Skip to content"),1)],64))}}),Ss=g(Vs,[["__scopeId","data-v-0b0ada53"]]),Ts=p({__name:"Layout",setup(e){const{isOpen:t,open:n,close:a}=D(),o=Q();F(()=>o.path,a),ut(t,a);const{frontmatter:r}=L(),l=qe(),v=y(()=>!!l["home-hero-image"]);return Te("hero-image-slot-exists",v),(f,$)=>{const V=W("Content");return i(r).layout!==!1?(s(),u("div",{key:0,class:N(["Layout",i(r).pageClass])},[c(f.$slots,"layout-top",{},void 0,!0),k(Ss),k(Ye,{class:"backdrop",show:i(t),onClick:i(a)},null,8,["show","onClick"]),k(ms,null,{"nav-bar-title-before":h(()=>[c(f.$slots,"nav-bar-title-before",{},void 0,!0)]),"nav-bar-title-after":h(()=>[c(f.$slots,"nav-bar-title-after",{},void 0,!0)]),"nav-bar-content-before":h(()=>[c(f.$slots,"nav-bar-content-before",{},void 0,!0)]),"nav-bar-content-after":h(()=>[c(f.$slots,"nav-bar-content-after",{},void 0,!0)]),"nav-screen-content-before":h(()=>[c(f.$slots,"nav-screen-content-before",{},void 0,!0)]),"nav-screen-content-after":h(()=>[c(f.$slots,"nav-screen-content-after",{},void 0,!0)]),_:3}),k(ra,{open:i(t),onOpenMenu:i(n)},null,8,["open","onOpenMenu"]),k(Ls,{open:i(t)},{"sidebar-nav-before":h(()=>[c(f.$slots,"sidebar-nav-before",{},void 0,!0)]),"sidebar-nav-after":h(()=>[c(f.$slots,"sidebar-nav-after",{},void 0,!0)]),_:3},8,["open"]),k(Wn,null,{"page-top":h(()=>[c(f.$slots,"page-top",{},void 0,!0)]),"page-bottom":h(()=>[c(f.$slots,"page-bottom",{},void 0,!0)]),"not-found":h(()=>[c(f.$slots,"not-found",{},void 0,!0)]),"home-hero-before":h(()=>[c(f.$slots,"home-hero-before",{},void 0,!0)]),"home-hero-info-before":h(()=>[c(f.$slots,"home-hero-info-before",{},void 0,!0)]),"home-hero-info":h(()=>[c(f.$slots,"home-hero-info",{},void 0,!0)]),"home-hero-info-after":h(()=>[c(f.$slots,"home-hero-info-after",{},void 0,!0)]),"home-hero-actions-after":h(()=>[c(f.$slots,"home-hero-actions-after",{},void 0,!0)]),"home-hero-image":h(()=>[c(f.$slots,"home-hero-image",{},void 0,!0)]),"home-hero-after":h(()=>[c(f.$slots,"home-hero-after",{},void 0,!0)]),"home-features-before":h(()=>[c(f.$slots,"home-features-before",{},void 0,!0)]),"home-features-after":h(()=>[c(f.$slots,"home-features-after",{},void 0,!0)]),"doc-footer-before":h(()=>[c(f.$slots,"doc-footer-before",{},void 0,!0)]),"doc-before":h(()=>[c(f.$slots,"doc-before",{},void 0,!0)]),"doc-after":h(()=>[c(f.$slots,"doc-after",{},void 0,!0)]),"doc-top":h(()=>[c(f.$slots,"doc-top",{},void 0,!0)]),"doc-bottom":h(()=>[c(f.$slots,"doc-bottom",{},void 0,!0)]),"aside-top":h(()=>[c(f.$slots,"aside-top",{},void 0,!0)]),"aside-bottom":h(()=>[c(f.$slots,"aside-bottom",{},void 0,!0)]),"aside-outline-before":h(()=>[c(f.$slots,"aside-outline-before",{},void 0,!0)]),"aside-outline-after":h(()=>[c(f.$slots,"aside-outline-after",{},void 0,!0)]),"aside-ads-before":h(()=>[c(f.$slots,"aside-ads-before",{},void 0,!0)]),"aside-ads-after":h(()=>[c(f.$slots,"aside-ads-after",{},void 0,!0)]),_:3}),k(Xn),c(f.$slots,"layout-bottom",{},void 0,!0)],2)):(s(),_(V,{key:1}))}}}),Ns=g(Ts,[["__scopeId","data-v-5d98c3a5"]]),xs={Layout:Ns,enhanceApp:({app:e})=>{e.component("Badge",Re)}};export{uo as c,xs as t,L as u};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as o,o as t,c as d,a2 as i}from"./chunks/framework.CNDaxUod.js";const u=JSON.parse('{"title":"Board widgets","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/board-widgets.md","filePath":"contributor-guide/architecture/board-widgets.md"}'),a={name:"contributor-guide/architecture/board-widgets.md"};function c(n,e,r,s,l,h){return t(),d("div",null,[...e[0]||(e[0]=[i('<h1 id="board-widgets" tabindex="-1">Board widgets <a class="header-anchor" href="#board-widgets" aria-label="Permalink to "Board widgets""></a></h1><h2 id="board-widgets-beyond-the-fixed-six" tabindex="-1">Board widgets beyond the fixed six <a class="header-anchor" href="#board-widgets-beyond-the-fixed-six" aria-label="Permalink to "Board widgets beyond the fixed six""></a></h2><p>(Components below live in <code>src/view/widgets/</code>, one file per widget name, e.g. <code>JobFailures.tsx</code>, <code>MemoryUtilization.tsx</code>.)</p><p>Six extra cards render in the Findings tab's active grid alongside the fixed spec §5 six: Incomplete Run, Job Failures, Caching Opportunities, Config Audit, Plan Advisor, and Autoscaling Churn, all <code>region: 'action'</code> in <code>detector-registry.tsx</code>:</p><ul><li><p><strong>Incomplete Run</strong> (tag <code>INCMP</code>, <code>IncompleteRun.tsx</code>): app-level "the capture never finished" caveat (DETECTORS entry <code>incompleteRun</code>). Fires whenever <code>app.startTime</code> was observed but <code>app.endTime</code> was not, i.e. no <code>SparkListenerApplicationEnd</code> in the log: an in-flight job, a rotated-away log, or a capture cut short. Order 5, ahead of every other detector, so its card is first among the Findings tab's <code>action</code>-region active widgets when present. The Findings tab's recommendation rollup (rendered by <code>FixTheseFirst.tsx</code>) excludes <code>incompleteRun</code> from that list outright (see <a href="./widget-rendering.html#widget-rendering-order-fixed-spec-§5">Widget rendering order</a>): it's a pipeline-completeness caveat, not an addressable fix, so it never competes with other findings for a ranked slot there. Self-gates to <code>null</code> (no card in the DOM) once the run completed normally. Unrelated to <code>evidence-availability.ts</code>'s own <code>trustworthy</code> gate (see <a href="./worker-protocol.html#evidence-availability-contract-v1">Evidence-availability contract</a>): that ledger only downgrades <em>absence</em> conclusions for individual evidence categories, never becomes a <code>DETECTORS</code> finding itself, and this card does not read it. No <code>docAnchor</code> is set, the same deviation as Autoscaling Churn below: this is a tool-specific signal with no upstream <code>spark-tuning-reference</code> section.</p></li><li><p><strong>Job Failures</strong> (tag <code>JOBS</code>): app-level job-failure-rate rollup (DETECTORS entry <code>jobFailureRate</code> in <code>src/detectors.ts</code>; origin Dr. Elephant <code>JobsHeuristic</code>), computed from <code>app.jobs</code> (<code>SparkListenerJobEnd</code> results): ≥10% info, ≥30% warning, ≥50% critical. Complements the per-stage, task-level Failed Tasks card (tag <code>FAIL</code>).</p></li><li><p><strong>Caching Opportunities</strong> (tag <code>CACHE</code>, <code>CachingOpportunity.tsx</code>): app-level SQL relation-reuse detector (DETECTORS entry <code>cachingOpportunity</code>), computed from <code>ctx.sql</code>. It walks each execution's <code>planTree</code> and keys every scan by its stable pre-AQE identity via <code>scanRelationId</code> (<code>plan-summary.ts</code>): <code>parquet:<db.table></code>, <code>delta:<db.table></code>, <code>jdbc:<schema.table></code>. That dedupes relations within one execution (self-joins count once) and flags any relation scanned by <code>>= minExecutions</code> (2) distinct executions. Relation identity comes from the catalog-qualified scan name (nodeName, e.g. <code>Scan parquet spark_catalog.db.t</code>), not the <code>Location:</code> path, since Spark truncates that path at ~100 chars and points it at <code>_delta_log</code> for Delta tables; internal Delta-log metadata scans are dropped. Read bytes come from the scan's <code>size of files read</code> metric (<code>0</code>/unknown for JDBC). Findings carry <code>executionReuse</code> (<code>value</code> = distinct-execution count), <code>relation</code>, <code>format</code>, <code>executionIds</code>, <code>totalReadBytes</code>, and a size-aware <code>recommendation</code> (<code>confidence:'low'</code>, <code>validationRequired</code>). Renders nothing when clean (no card in the DOM). One row per relation: name + format badge, reuse count, <code>Data read</code> (<code>formatBytes</code>, em-dash when unknown), recommendation, sorted by <code>totalReadBytes</code> then reuse count descending. Rows reveal 6 initially, then up to 30 more per click. Pure-RDD-API apps (no SQL executions) produce no finding: a deliberate trade-off replacing the former RDD-lineage heuristic, which surfaced only internal query-engine RDDs on DataFrame/SQL workloads.</p><p>It also detects composite reuse. When the same join/union subtree (not just a leaf scan) recurs across <code>>= minExecutions</code> distinct SQL executions, the detector emits one <code>variant:'composite'</code> finding recommending caching the derived join/union result instead of two independent leaf-relation rows, and suppresses the leaf findings for relations fully covered by it. A relation reused beyond the composite's executions keeps a residual leaf finding for just the uncovered executions. Composite identity is structural: an anchor plan-shape fingerprint (<code>findCompositeCandidates</code>/<code>computePlanShapes</code>'s <code>opts.includeDetail</code> path, <code>detectors.ts</code>) folds the join/union node's own normalized <code>detail</code> (join type, columns, literals, with expr ids, <code>plan_id=</code>, codegen-stage numbers, and AQE's BuildLeft/BuildRight stripped, and commutative equality operands canonicalized) plus its children's detail-free shapes. Descendant nodes never contribute detail text, only structural shape, so a shared join reused across differently-filtered pre-join scans still matches (a deliberate trade-off) while a different join <em>condition</em> on the same tables does not collide. Nested composites (an inner join reused both standalone and inside an outer join) dedupe one level at a time: an inner composite fully covered by a qualifying outer composite's execution set is suppressed entirely, and a superset recomputes a residual finding over just its extra executions. Reuse via pure projection/aggregation with no join/union underneath stays leaf-level, not modeled as composite. <code>variant:'composite'</code> findings carry <code>operator</code> (<code>'join'</code>|<code>'union'</code>), <code>relations</code> (leaf relations under the composite, for display), and <code>format:'derived'</code> (a sentinel: composites have no scan storage format). They render a <code>JOIN</code>/<code>UNION</code> operator badge (styled distinct from the format badges) plus a low-confidence marker (<code>confidence:'low'</code>, <code>validationRequired</code>) in <code>CachingOpportunity.tsx</code>.</p></li><li><p><strong>Autoscaling Churn</strong> (tag <code>CHRN</code>, <code>AutoscalingChurn.tsx</code>): app-level short-lived-executor detector (DETECTORS entry <code>autoscalingChurn</code> in <code>src/detectors.ts</code>; reuses the same <code>executorsAdded</code>/<code>executorsRemoved</code> matching logic as <code>utilization</code>). For each added executor, finds its matching removal event (falling back to <code>app.endTime</code> for an executor still alive when the log ends) and flags it short-lived if its lifetime is under 2 minutes (<code>thresholds.shortLivedMs</code>). Warns above 30% short-lived, escalates to critical above 60% (<code>confidence: 'low'</code>: these percentages are an unvalidated design-spike estimate, not yet checked against real autoscaling-heavy logs). Returns no finding below 5 total executors (noise floor) or when <code>app.endTime</code> is missing (truncated/still-running log). The widget itself is unchanged apart from a finding-driven verdict banner (impact dot + <code>CHRN</code> tag + recommendation) above its existing add/remove chart. The chart's muted scale-down bar coloring is untouched. No <code>docAnchor</code> is set yet: the upstream <code>shuffle-works/spark-tuning-reference</code> docs repo has no <code>bottleneck-autoscaling-churn</code> section (verified 2026-08-19), so the <code>CHRN</code> tag currently renders with no docs-panel link (a tracked follow-up, not a defect).</p></li><li><p><strong>Config Audit</strong> (tag <code>CFG</code>): static Spark-config sanity findings derived from <code>app.config</code>/<code>app.resources</code> (parsed from <code>SparkListenerEnvironmentUpdate</code>; origin Dr. Elephant <code>ConfigurationHeuristic</code>): dynamic-allocation vs. shuffle-service mismatch, inverted/missing autoscaling bounds, non-Kryo serializer, low executor <code>memoryOverhead</code>. Computed outside the runtime bottleneck catalog: its findings live in the separate <code>configFindings</code> stream, not <code>catalog</code>. But <code>FixTheseFirst</code>/<code>Alerts</code> both merge <code>catalog</code> and <code>configFindings</code> (neither reads <code>region</code> any more), so a Config Audit finding is still eligible for a row in the Findings tab's recommendation rollup and still gets its own card in the Findings tab's active grid, alongside genuine bottleneck-catalog findings.</p></li><li><p><strong>Plan Advisor</strong> (tag <code>PLAN</code>): SQL-plan-level findings computed from <code>appModel.sql</code>'s resolved <code>planTree</code> (DETECTORS entries <code>duplicatePlanSubtree</code>, <code>smallFiles</code>, <code>broadcastSizing</code> in <code>src/detectors.ts</code>; origins sparkDoctor and dataflint <code>JoinToBroadcastAlert</code>/<code>BroadcastTooLargeAlert</code>/<code>MemorySQLInputOutputAlerts</code>): repeated plan subtrees (≥3 nodes, ≥2 occurrences, critical if the repeated root is an <code>Exchange</code>), small-files read/write (>100 files averaging ❤️ MiB), and broadcast-join sizing in both directions (missed- broadcast info finding, over-broadcast warning at >1 GB). Renders as one card (<code>PlanFindings.tsx</code>) since all three share one <code>order</code> band (130-132) and <code>orderedWidgets()</code>'s shared-component-identity de-dupe collapses them into it. Its three <code>docAnchor</code>s (<code>#bottleneck-duplicate-plan-subtree</code>, <code>#bottleneck-small-files</code>, <code>#bottleneck-broadcast-sizing</code>) are not yet live in the vendored docs site, so <code>npm run update-docs</code> will abort until upstream <code>spark-doc</code> adds them; this does not block the detector logic itself.</p></li></ul><p><code>detector-registry.tsx</code>'s <code>REGISTRY</code> still carries <code>region: 'reference'</code> on five entries: <code>memoryUtilization</code> and <code>utilization</code> (both resolving to <code>MemoryUtilization</code>), <code>cacheUtilization</code> (resolving to <code>CacheUtilization</code>), <code>coreLocality</code> (resolving to <code>CoreUsageArea</code>), and <code>broadcastSizing</code> (resolving to <code>PlanFindings</code>). <code>broadcastSizing</code> is a dead key, though: the <code>broadcastSizing</code> <code>DETECTORS</code> entry only ever emits <code>underBroadcast</code>/<code>overBroadcast</code> findings (both <code>region: 'action'</code>, rendered via the Findings tab's Plan Advisor card; see the <code>REGISTRY</code> doc comment in <code>src/view/detector-registry.tsx</code> and the <a href="./../testing.html#testing-layout"><code>detector-registry.test.tsx</code> completeness check</a>), so no <code>finding.type === 'broadcastSizing'</code> object is ever produced. It still shows up on the board, though: the per-type clean-check list (see below) reaches it like any other type with zero findings, so it renders a permanent <code>CleanCheckRow</code> ("broadcast sizing · [threshold summary] · No fix needed.") on every run, never a real card, since no finding of that type is ever emitted. Autoscaling Churn, the other executor-provisioning-lifecycle detector alongside <code>utilization</code>/<code>memoryUtilization</code>, was deliberately given <code>action</code> rather than <code>reference</code> (see "Board widgets beyond the fixed six" above).</p><p><code>region</code> decides one thing (see <a href="./widget-rendering.html#widget-rendering-order-fixed-spec-§5">Widget rendering order</a>): whether a widget always mounts. <code>isAlwaysMountedType()</code> flags exactly the <code>reference</code>-region types whose component isn't shared with an <code>action</code>-region sibling: Memory Utilization, Cache Utilization, and Core Usage by Locality. Those three mount unconditionally from <code>appModel</code> in their own small grid inside the Findings tab, regardless of finding state; <code>broadcastSizing</code> is <code>reference</code> too but shares <code>PlanFindings</code> with two <code>action</code> siblings (<code>duplicatePlanSubtree</code>, <code>smallFiles</code>) plus two more real emitted types (<code>underBroadcast</code>, <code>overBroadcast</code>); its own clean-check line renders unconditionally regardless of whether any of those four fire, since <code>broadcastSizing</code> itself is a distinct, always-zero-finding type. Every other <code>REGISTRY</code> widget still renders unconditionally as either an active card or a clean-check line, and <code>region</code> still sets <code>orderedWidgets()</code>'s sort order within the active grid (<code>action</code> components first, <code>reference</code> ones after, though every surviving active-grid entry today is already <code>action</code>-region, so this sort currently has nothing to do). The Full app report tab is structural-only and reads no <code>REGISTRY</code> entry. So Memory Utilization, Cache Utilization, and Core Usage by Locality, the only three widgets with a real finding still tagged <code>region: 'reference'</code>, render in the Findings tab's always-visible grid rather than inside a separate Reference region:</p><ul><li><strong>Memory Utilization</strong> (tag <code>MEM</code>): app-level card combining three sub-findings from the <code>memoryUtilization</code> DETECTORS entry (<code>src/detectors.ts</code>): idle-cores rate (busy-core-time from the worker's run-aggregates sweep vs. peak-cores × wall-clock; origin dataflint <code>WastedCoresAlertsReducer</code>), per-executor memory bands (peak heap vs. allocated, gated on <code>spark.eventLog.logStageExecutorMetrics</code>: a distinct <code>dataUnavailable</code> finding renders when that config was off; origin dataflint <code>MemoryAlertsReducer</code>), and an unverified memory-waste model (<code>confidence:'low'</code>, Dr. Elephant <code>SparkMetricsAggregator</code>'s 1.5× buffer). The driver-memory half of the original spec is dropped: the worker only extracts <em>allocated</em> <code>spark.driver.memory</code>, never a driver actual-usage metric, so there is nothing to band against. The separate <code>utilization</code> DETECTORS entry (an <code>avgUtilization</code> info finding: active-executor-time fraction below 60%, formerly rendered via <code>ExecutorTimeline</code>) now resolves to this same <code>MemoryUtilization</code> component and region too, so its rows render alongside the <code>memoryUtilization</code> sub-findings rather than in the Findings tab's Executor Timeline card.</li><li><strong>Cache Utilization</strong> (tag <code>CSTOR</code>): app-level card driven by the <code>cacheUtilization</code> DETECTORS entry (<code>src/detectors.ts</code>), evaluating two per-RDD proxies over <code>ctx.app.rddInfo</code> storage snapshots since Spark event logs carry no runtime block-access/read-count data: partial caching (<code>numCachedPartitions / numPartitions < 0.90</code>, <code>< 0.50</code> for the warning tier) and disk spillover for <code>MEMORY_AND_DISK*</code> RDDs (<code>diskSize / (memorySize + diskSize) > 0.15</code>, <code>> 0.40</code> for the warning tier; <code>DISK_ONLY</code> RDDs are never flagged). Every finding carries <code>confidence: 'medium'</code>, because the ratio is a point-in-time storage snapshot from stage-submission events, not a runtime read-count. The existing RDD table (ported from the legacy <code>src/widgets/cache-utilization.js</code> canvas widget) still renders unconditionally; flagged rows get an inline <code>CSTOR</code> tag next to the RDD name, and every flagged RDD's recommendation renders below the table, worst-first.</li><li><strong>Core Usage by Locality</strong> (tag <code>LOCAL</code>, <code>CoreUsageArea.tsx</code>): app-level non-local-task-ratio threshold (DETECTORS entry <code>coreLocality</code>; the other half of dataflint's "Wasted Cores Ratio" alert, the idle-core half already covered by Memory Utilization's <code>idleCores</code> variant above). Sums <code>RACK_LOCAL</code> + <code>ANY</code> task counts against total task count across every stage's <code>stage.localityStats</code> (pure reducer <code>computeCoreLocalityRatio</code>, <code>src/core-locality-ratio.ts</code>). <code>NO_PREF</code> stays in the denominator only, since it's what shuffle-read stages legitimately report with no locality problem. Below 50 total tasks or below a 15% non-local ratio: no finding; 15%-35%: warning; >= 35%: critical (<code>confidence:'low'</code>, unvalidated design-spike thresholds, same convention as the memory-waste model above). The widget's always-rendered stacked-area chart (<code>src/core-usage-locality.ts</code>) is unaffected. The finding only adds a threshold/impact-band section above it, a per-stage non-local breakdown below it (shown whenever any non-local tasks exist at all, independent of whether the aggregate crossed threshold), and a one-line cross-reference to Memory Utilization when its <code>idleCores</code> finding also fired (idle-core ratio itself is not duplicated here).</li></ul><p>Documented ALL-CAPS tag vocabulary: <code>SKEW</code>, <code>SHFL</code>, <code>SPILL</code>, <code>GC</code>, <code>COLD</code>, <code>UTIL</code>, <code>MEM</code> (Memory Utilization), <code>CSTOR</code> (Cache Utilization), <code>LOCAL</code> (Core Usage by Locality), plus <code>FAIL</code> (Failed Tasks), <code>JOBS</code> (Job Failures), <code>CFG</code> (Config Audit), <code>PLAN</code> (Plan Advisor), <code>SFAIL</code> (stage failed outright), <code>PART</code> (partition sizing), <code>SLOW</code> (stage overall slowness), <code>SHAPE</code> (stage shape smells), <code>CACHE</code> (caching opportunity) and <code>CHRN</code> (autoscaling churn).</p><p>ETL Phase Attribution (<code>src/etl-phases.ts</code> + <code>EtlPhases.tsx</code>), What-If Executor Scaling (<code>src/scaling-sim.ts</code> + <code>ScalingSim.tsx</code>, design spike: makespan predictions are unvalidated and carry a Model Error indicator), and Compute Efficiency (<code>src/efficiency-model.ts</code> + <code>EfficiencyModel.tsx</code>, design spike: driver-vs-executor waste split, two theoretical floors, and the §6 right-sizing copy) are main-thread report modules, not <code>DETECTORS</code> entries: descriptive lenses with no impact-band threshold, rendered unconditionally into the Full app report tab rather than participating in the bottleneck catalog. <code>src/job-groups.ts</code> (<code>checkConcurrentJobGroups</code>) is likewise a report helper: it flags when concurrent SQL-execution job groups make wall-clock-based estimates unreliable, and both the scaling simulator and the efficiency model gate their precision on it, showing a caveat banner rather than suppressing the estimate.</p><h2 id="confidence-metadata" tabindex="-1">Confidence metadata <a class="header-anchor" href="#confidence-metadata" aria-label="Permalink to "Confidence metadata""></a></h2><p>Best-effort (non-deterministic) findings carry a <code>confidence</code> + <code>validationRequired</code> marker, rendered inline by each consuming widget (e.g. <code>Spill.tsx</code>, <code>PlanFindings.tsx</code>, <code>EfficiencyModel.tsx</code>) when <code>confidence</code> is present and not <code>'high'</code>. There is no shared helper: each widget renders its own muted "N confidence: verify" text, with the <code>title</code> attribute carrying <code>validationRequired</code> as a tooltip. Spill classification is <code>medium</code> when classified (skew/volume) and <code>low</code> when unclassified; plan-summary warnings are <code>low</code>. Deterministic detectors stay unmarked, treated as high confidence.</p><h2 id="visual-system" tabindex="-1">Visual system <a class="header-anchor" href="#visual-system" aria-label="Permalink to "Visual system""></a></h2><p>Tailwind CSS v4 + shadcn/ui (Base UI primitives). The design tokens (colors, including the telemetry-console dark palette) are defined as CSS variables in an <code>@theme inline</code> block and consumed via Tailwind utility classes; there is no more hand-authored BEM CSS. Theme polarity is unchanged from the legacy app: dark = bare <code>:root</code> (no attribute), light = <code>:root[data-theme="light"]</code>, toggled by <code>src/theme/ThemeProvider.tsx</code> and persisted to <code>localStorage</code>. <code>index.html</code>'s inline FOUC-prevention script still runs before React mounts. Mono is still the typeface for numerics (<code>--font-mono</code>), applied via a <code>.num</code>-equivalent Tailwind utility per call site rather than one global class. Charts are Recharts (<code>src/view/charts/ChartTheme.tsx</code>'s <code>CHART_COLORS</code>, read from the same CSS tokens) instead of Chart.js. React re-renders on theme toggle, so chart colors update live with the theme, unlike the old Chart.js canvases, which were built once per parse. <code>ChartFrame</code> can also expose the underlying rows through a toggleable accessible table and copy them to the clipboard as TSV.</p><h2 id="templating-xss-safe" tabindex="-1">Templating (XSS-safe) <a class="header-anchor" href="#templating-xss-safe" aria-label="Permalink to "Templating (XSS-safe)""></a></h2><p>JSX auto-escapes every interpolated value by default. The hand-rolled auto-escaping <code> html</code> `` tagged template (<code>src/widgets/utils.js</code>) and its <code>no-raw-innerhtml</code> guard test are gone, and no <code>.innerHTML</code> assignment is left anywhere in the view layer.</p><p>One Base UI-specific gotcha carried no equivalent in the legacy app: <code>WidgetCard.tsx</code> passes <code>aria-expanded={String(open) as 'true' | 'false'}</code> rather than the raw boolean, because Base UI's <code>Collapsible.Trigger</code> otherwise overrides a boolean <code>aria-expanded</code> prop with its own internal state via prop merging. The explicit <code>String()</code> cast keeps the rendered attribute in sync with this app's own <code>open</code> state.</p>',17)])])}const p=o(a,[["render",c]]);export{u as __pageData,p as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as o,o as t,c as d,a2 as i}from"./chunks/framework.CNDaxUod.js";const u=JSON.parse('{"title":"Board widgets","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/board-widgets.md","filePath":"contributor-guide/architecture/board-widgets.md"}'),a={name:"contributor-guide/architecture/board-widgets.md"};function c(n,e,r,s,l,h){return t(),d("div",null,[...e[0]||(e[0]=[i("",17)])])}const p=o(a,[["render",c]]);export{u as __pageData,p as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as t,o,c as d,a2 as a}from"./chunks/framework.CNDaxUod.js";const p=JSON.parse('{"title":"Detector contract","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/detector-contract.md","filePath":"contributor-guide/architecture/detector-contract.md"}'),r={name:"contributor-guide/architecture/detector-contract.md"};function i(c,e,s,n,l,h){return o(),d("div",null,[...e[0]||(e[0]=[a('<h1 id="detector-contract" tabindex="-1">Detector contract <a class="header-anchor" href="#detector-contract" aria-label="Permalink to "Detector contract""></a></h1><p><code>src/detectors.ts</code> is the single source of Spark-optimization logic: one declarative <code>DETECTORS</code> entry per pattern, each carrying <code>type</code>, <code>scope</code> (<code>stage</code> / <code>app</code> / <code>config</code> / <code>sql</code>), <code>order</code>, <code>fixEffort</code>, a <code>thresholds</code> object, impactBand/copy, a <code>docAnchor</code>, and a co-located <code>detect()</code> method. Both consumers are thin loops over that array:</p><ul><li><p><code>src/analyzer.ts</code>: <code>analyze()</code> runs every entry regardless of scope, skipping only <code>inScorecard:false</code> ones; <code>auditConfig()</code> separately runs the <code>scope:'config'</code> entries. The four <code>configAudit</code> entries stay out of the bottleneck catalog because each sets <code>inScorecard:false</code>, not because of <code>scope:'config'</code>: a future config-scope detector without that flag would run through <code>analyze()</code> too. Each finding is stamped with its entry's <code>docAnchor</code>.</p></li><li><p><code>src/view/detector-registry.tsx</code>: a <code>REGISTRY: Record<findingType, {component, region}></code> replaces <code>dashboard-renderer.js</code>'s <code>render:</code> bindings. <code>orderedWidgets()</code> walks <code>DETECTORS</code> ascending by <code>order</code>, de-dupes on <code>component</code> identity, then sorts <code>action</code>-region components before <code>reference</code>-region ones. The de-duped groups:</p><ul><li><code>slowHost</code> / <code>stageSlowness</code> / <code>straggler</code> / <code>speculationWaste</code> / <code>coldStart</code> → the one <code>ExecutorTimeline</code></li><li><code>stageShape</code> / <code>tinyTask</code> → <code>TaskSkew</code></li><li><code>partitionSizing</code> → <code>ShuffleIO</code></li><li><code>stageFailed</code> / <code>retryWaste</code> → <code>Failures</code></li><li><code>memoryUtilization</code> / <code>utilization</code> → <code>MemoryUtilization</code></li></ul><p>Each widget component receives the full catalog and self-gates when it has nothing to show, rendering <code>null</code> or a muted "no issue" card for the always-visible ones. <code>orderedWidgets()</code> itself has no empty/non-empty branching, since it iterates the static <code>DETECTORS</code> import, not the runtime <code>catalog</code>.</p></li></ul><p>Thresholds live only in each entry's <code>thresholds</code>; see <a href="#bottleneck-thresholds-spec-§4">Bottleneck thresholds</a>.</p><h2 id="the-fixeffort-field" tabindex="-1">The <code>fixEffort</code> field <a class="header-anchor" href="#the-fixeffort-field" aria-label="Permalink to "The `fixEffort` field""></a></h2><p>Each <code>Detector</code> entry also carries <code>fixEffort: 'config' | 'code' | 'rearchitect'</code>, alongside <code>order</code> and <code>thresholds</code>: a rough estimate of how much work resolving the finding takes. It's domain-unverified as shipped (the impact/effort redesign's spec open question #1): nobody with deep Spark-tuning experience has reviewed the per-detector assignments yet, so treat the current mapping as a first pass, not settled guidance.</p><p>No view currently reads it. The quadrant impact/effort bucketing this field was meant to feed (<code>bucketFinding</code>/<code>effortTier</code>/<code>computeImpactMagnitude</code> in a since-deleted <code>src/quadrant-bucket.ts</code>, gated behind a <code>FIX_EFFORT_MAPPING_REVIEWED</code> flag that never flipped to <code>true</code>) was removed as dead code in the recommendations-consolidation redesign: <code>FixTheseFirst</code> (<code>src/view/widgets/FixTheseFirst.tsx</code>) ranks purely by impact magnitude. See <a href="./widget-rendering.html#widget-rendering-order-fixed-spec-§5">Widget rendering order</a> for how it ranks findings today.</p><p>Two shared helpers back multiple detectors and reports. <code>src/plan-tree-walk.ts</code>'s <code>walkPlanTree(root, visit, {dedupe})</code> is the iterative pre-order plan-tree traversal used by <code>detectors.ts</code> and every <code>plan-*.ts</code> module (<code>plan-summary.ts</code>, <code>plan-duration-attribution.ts</code>, <code>plan-node-detail.ts</code>, <code>plan-dot.ts</code>). <code>src/core-count.ts</code>'s <code>computeTotalCores(app, executorsAdded)</code> is the shared core-count logic used by <code>detectors.ts</code>, <code>efficiency-model.ts</code>, <code>scaling-sim.ts</code>, and <code>wasted-core-hours.ts</code>.</p><h2 id="cross-detector-suppression" tabindex="-1">Cross-detector suppression <a class="header-anchor" href="#cross-detector-suppression" aria-label="Permalink to "Cross-detector suppression""></a></h2><p>An entry may declare an optional <code>suppressWhen(finding, out)</code> method. <code>analyzer.ts</code>'s <code>push()</code>, the single choke point every finding passes through, calls it per-finding, after the null guard and before the push, and drops the finding silently when it returns <code>true</code>. <code>out</code> is the findings accumulated so far. Since <code>analyze()</code>'s loop is detector-outer / stage-inner, every finding from a detector declared earlier in <code>DETECTORS</code> is already in <code>out</code> by the time a later detector runs, for every stage. That makes the pattern purely declaration-order-driven: the suppressing detector must be declared earlier in the <code>DETECTORS</code> array than the suppressed one.</p><p><code>stageSlowness</code> uses this to defer to <code>slowHost</code>. It is spliced immediately after the <code>slowHost</code> entry regardless of its <code>order</code> field (<code>order</code> only controls render sequencing, not evaluation order), and <code>tests/analyzer.test.js</code>'s "detector contract" suite asserts the array-index ordering so a future reorder can't silently break the suppression. The mechanism is deliberately minimal: a same-array, predicate-in-<code>push()</code> filter, not a general dependency graph. <code>auditConfig()</code>'s own <code>push()</code> call is unaffected, since <code>scope:'config'</code> entries declare no <code>suppressWhen</code>.</p><h2 id="per-operator-duration-attribution" tabindex="-1">Per-operator duration attribution <a class="header-anchor" href="#per-operator-duration-attribution" aria-label="Permalink to "Per-operator duration attribution""></a></h2><p><code>src/plan-duration-attribution.ts</code> (entry <code>attributeStageDurationToPlan(planTree, stagesById, sqlExec)</code>, origin dataflint's GraphDurationAttribution) approximates how a SQL execution's stage wall-time splits across plan operators, returning a <code>Map<planNode, milliseconds></code>. It cuts the plan tree at Exchange boundaries into connected components. Each component receives a stable pre-order identity and separate parent/depth/traversal metadata; the identity itself does not encode its count of Exchange ancestors. It zips components deepest-first by that explicit depth against submission-ordered stage IDs, then apportions each matched stage's wall-time across that component's nodes by timing-metric weight, falling back to an even split when no node carries a timing metric.</p><p>This is best-effort inference, not measurement. Spark's event model exposes no ground truth for per-operator time within a stage; the Exchange-boundary segmentation and deepest-component-to-earliest-stage zip are heuristics. Treat the per-operator numbers as directional hints, never as authoritative timings, and do not build hard thresholds or findings on top of them.</p><h2 id="stage-id-attribution-for-plan-advisor-findings" tabindex="-1">Stage-ID attribution for Plan Advisor findings <a class="header-anchor" href="#stage-id-attribution-for-plan-advisor-findings" aria-label="Permalink to "Stage-ID attribution for Plan Advisor findings""></a></h2><p>The Plan Advisor detectors (<code>duplicatePlanSubtree</code>, <code>smallFiles</code>, <code>broadcastSizing</code> in <code>src/detectors.ts</code>) each attribute their finding to a narrowed <code>stageIds</code> set rather than the whole SQL execution: <code>PlanNode.stageIds</code> is resolved once per plan tree at parse time by unioning, per node, every metric's accumulator ID against a <code>taskAccumStages: Map<accumulatorId, Set<stageId>></code> built while parsing <code>TaskEnd</code> events, then clipping the result to the execution's own stage set. An accumulator ID occasionally points to a <em>different</em> execution's stages, e.g. a <code>ReusedSubquery</code> computed once and reused verbatim, and the clip prevents misattributing that other execution's work. Each detector unions its implicated node(s)' <code>stageIds</code> and falls back to the execution-wide set only when no implicated node has any coverage; a finding never partially blends a narrowed set with the execution-wide one. When an execution has no stage universe at all (no jobs ever recorded against it, which is true for 42% of real-log SQL executions with a plan tree, typically job-less/driver-only executions), the clip drops every candidate stage ID instead of passing them through: every node in that execution's tree ends up with no <code>stageIds</code> anywhere, same "coverage is inherently partial" framing as below. (An earlier version of this clip treated "no stage universe" as "no clip," which let a foreign accumulator ID collision, e.g. the <code>ReusedSubquery</code> case above, leak another execution's stages into a job-less execution's nodes; the clip is now unconditional on <code>executionStageIds</code> being present.)</p><p>Coverage is inherently partial, by Spark's own design, not a bug in this mechanism: whole-stage-codegen wrapper nodes (<code>InputAdapter</code>, and other purely structural passthrough markers) carry no accumulators at all, and <code>BroadcastExchangeExec</code>'s own metrics are computed entirely on the driver and never appear on any <code>TaskEnd</code> (real Spark behavior: its immediate child, which does carry executor-side metrics, is unioned in instead, see <code>overBroadcast</code>'s wiring). A <code>TaskEnd</code> arriving after its stage has already been finalized is also silently excluded from <code>taskAccumStages</code>, consistent with the parser's existing out-of-order tolerance elsewhere.</p><p><code>planTree</code> itself is kept current against Spark's adaptive query execution (AQE) re-plans: <code>SparkListenerSQLAdaptiveExecutionUpdate</code> events overwrite the execution's <code>sparkPlanInfo</code>/<code>physicalPlanDescription</code> last-write-wins, so accumulator-ID evidence is matched against the plan that actually ran rather than a stale pre-AQE snapshot.</p><p>No eviction/pruning is added to <code>taskAccumStages</code>, a deliberate choice, not an oversight: measured on real logs, it holds roughly 1,050 keys per compressed MB (9,850 keys on an 11.6 MB fixture, about 29,000 keys on a 28.1 MB fixture). Extrapolated to a 240MB+ log, the scale this tool targets (see <code>CLAUDE.md</code>), that is roughly 250,000 keys, around 45 MB of heap for an equivalent synthetic <code>Map<number, Set<number>></code>. Still small relative to this tool's other in-memory state, but higher than the fixture-only measurements taken when this mechanism was built suggested.</p><p>Per-execution pruning (e.g. dropping a <code>taskAccumStages</code> entry once its stage finalizes or its owning SQL execution resolves, mirroring how <code>accumState</code> is cleared in <code>endSqlExecution</code>) is deliberately not done either: unlike <code>accumState</code>, <code>taskAccumStages</code> is one global, un-scoped map read by every execution's <code>resolvePlanTree</code> call, and the <code>ReusedSubquery</code> case above depends on a stage recorded under one execution still being visible when a later execution resolves. Pruning on any single execution's lifecycle would break that cross-execution lookup. What is bounded is the growth from a single pathological event: <code>TaskEndEventSchema</code>'s <code>Accumulables</code> array is capped at <code>MAX_ACCUMULABLES_PER_TASK</code> (10,000, <code>event-schemas.ts</code>), well above any real plan's per-task metric count, so a single crafted <code>TaskEnd</code> can't grow the map past that per-event bound; a <code>TaskEnd</code> exceeding it fails schema validation and the line is skipped (counted in <code>skippedLines</code>) like any other malformed event.</p><h2 id="bottleneck-thresholds-spec-§4" tabindex="-1">Bottleneck thresholds (spec §4) <a class="header-anchor" href="#bottleneck-thresholds-spec-§4" aria-label="Permalink to "Bottleneck thresholds (spec §4)""></a></h2><p>Every change to <code>src/detectors.ts</code> should reference this table.</p><p>Every finding's <code>impactBand</code> comes from one of two places. For any finding whose <code>impactEstimate</code> carries a <code>wallClock</code> estimate (the common case for most rules below), <code>analyzer.ts</code> calls <code>deriveImpactBand()</code> (<code>src/impact-band.ts</code>) immediately after <code>estimateImpact()</code>, which sets <code>.impactBand</code> purely from <code>wallClock.high</code> as a fraction of the app's total duration (<code>>= 2%</code> critical, <code>>= 0.5%</code> warning, else info: the same <code>floorPctWarn</code>/<code>floorPctCrit</code> values <code>skew</code>/<code>straggler</code> use for their own thresholds below). For those rules, the table below documents their firing gate plus their fixed fallback constant, which surfaces only when this run's finding of that type didn't get a wallClock estimate (a stage excluded from the occupancy sweep). For rules whose finding type never gets a wallClock estimate (<code>resourceOnly</code>/<code>informational</code> basis, e.g. <code>configAudit</code>, or a rule that keeps its own ratio-tiered classification per the design's Decision 2, e.g. <code>failures</code>), the full threshold table below is the real, displayed classification: <code>detectors.ts</code> sets <code>impactBand</code> directly and nothing overwrites it.</p><h3 id="fixed-fallback-only-usually-wallclock-derived-instead" tabindex="-1">Fixed fallback only (usually wallClock-derived instead) <a class="header-anchor" href="#fixed-fallback-only-usually-wallclock-derived-instead" aria-label="Permalink to "Fixed fallback only (usually wallClock-derived instead)""></a></h3><p>These rules' <em>band</em> tiers were deleted from <code>src/detectors.ts</code> (they were always overwritten by <code>deriveImpactBand</code> whenever a wallClock estimate was available); the constant in the last column is only a floor-case fallback. Their <em>firing</em> gate is untouched and still lives in each entry's <code>thresholds</code> object: it decides whether the rule reports anything, so it stays documented here in full.</p><table tabindex="0"><thead><tr><th>Rule</th><th>Fires when</th><th>Fallback</th></tr></thead><tbody><tr><td>Task skew</td><td><code>taskDurationP95 / taskDurationP50 > 3×</code> (<code>taskDurationMax / P50</code> for stages under <code>minTasksForP95</code> = 20 tasks), <strong>and</strong> the occupancy-clipped P95−P50 (or max−P50) delta is ≥ <code>floorPctWarn</code> = 0.5% of app runtime</td><td><code>warning</code></td></tr><tr><td>Shuffle read</td><td><code>shuffleReadBytes > minBytes</code> = 50 MiB</td><td><code>info</code></td></tr><tr><td>Partition sizing: skew (origin sparkDoctor)</td><td><code>shuffleReadMax > 5×</code> <code>shuffleReadP50</code> <strong>and</strong> <code>shuffleReadMax > 256 MiB</code></td><td><code>warning</code></td></tr><tr><td>Partition sizing: low parallelism (origin sparkDoctor)</td><td><code>shuffleReadBytes ≥ 1 GiB</code> <strong>and</strong> <code>taskCount ≤ 7</code></td><td><code>warning</code></td></tr><tr><td>Partition sizing: oversized partition (origin dataflint <code>MaxPartitionToBigAlert</code>)</td><td><code>shuffleReadMax ≥ 5 GiB</code></td><td><code>critical</code></td></tr><tr><td>GC</td><td><code>executorRunTime ≥ minRunTimeMs</code> = 10 s <strong>and</strong> <code>gcPct > 10%</code></td><td><code>warning</code></td></tr><tr><td>GC (low / cost)</td><td><code>executorRunTime ≥ 10 s</code> <strong>and</strong> <code>gcPct < lowInfoPct100</code> = 5% (checked only when the GC row above did not fire)</td><td><code>info</code></td></tr><tr><td>Spill (magnitude v2, origin sparkDoctor <code>SpillPressureDetector</code>/<code>SpillSkewDetector</code>)</td><td>any non-zero <code>memoryBytesSpilled</code>. The magnitude sub-table below classifies <em>how much</em>, but does not gate firing</td><td><code>warning</code></td></tr><tr><td>Cold start</td><td><code>firstStageSubmittedAt − app.startTime > gapSeconds</code> = 30 s</td><td><code>warning</code></td></tr><tr><td>Slow host: mean-duration ratio</td><td>stage has ≥ <code>minHosts</code> = 3 hosts (or executors) and ≥ <code>minTasks</code> = 15 tasks; then per host: mean task duration / overall median ≥ <code>ratioWarn</code> = 2.0× <strong>and</strong> host task-share ≥ <code>minShare</code> = 20% <strong>and</strong> host mean ≥ <code>floorMs</code> = 1000 ms (absolute-magnitude floor, rules out sub-second noise)</td><td><code>warning</code></td></tr><tr><td>Slow host: duration-share (origin dataflint)</td><td>same stage gate as the row above; then per host: ≥ <code>shareWarn</code> = 75% of the stage's total task-duration <strong>and</strong> ≥ <code>taskShareWarn</code> = 50% of its task count</td><td><code>warning</code></td></tr><tr><td>Stage slowness: absolute fallback (origin Dr. Elephant <code>StagesHeuristic</code>), suppressed when <code>slowHost</code> already fired</td><td>stage wall-clock duration ≥ <code>infoMin</code> = 15 min</td><td><code>info</code></td></tr><tr><td>Straggler / speculative-execution (origin sparkDoctor)</td><td><code>taskCount ≥ minTasks</code> = 10, <strong>and</strong> either any speculative task ran <strong>or</strong> straggler share > <code>shareWarn</code> = 5%. <code>warnPct</code>/<code>critPct</code> (10%/20% speculative share) and <code>floorPctWarn</code>/<code>floorPctCrit</code> (0.5%/2% of app runtime) no longer set the band; they rank the straggler-vs-speculative tiers that pick which <em>metric</em> the finding reports</td><td><code>info</code></td></tr><tr><td>Speculation waste (new)</td><td><code>speculationWastedAttempts ≥ minWasted</code> = 5 <strong>and</strong> <code>speculationWasteMs ≥ minWasteMs</code> = 60 s</td><td><code>warning</code></td></tr><tr><td>Retry waste</td><td><code>wastedAttempts ≥ minWasted</code> = 3 <strong>and</strong> <code>retryWasteMs ≥ minWasteMs</code> = 30 s, on a stage that still completed</td><td><code>warning</code></td></tr><tr><td>Tiny tasks</td><td><code>taskCount ≥ minTasks</code> = 100 <strong>and</strong> <code>taskDurationP50 ≤ maxP50</code> = 500 ms <strong>and</strong> <code>taskDurationP95 ≤ maxP95</code> = 1000 ms</td><td><code>info</code></td></tr><tr><td>Duplicate plan subtree (origin sparkDoctor)</td><td>a subtree of ≥ <code>minSubtreeSize</code> = 3 nodes whose shape fingerprint repeats ≥ <code>minOccurrences</code> = 2× in the plan</td><td><code>warning</code></td></tr><tr><td>Small files read/write (origin dataflint)</td><td>per read/write side: file count > <code>minFiles</code> = 100 <strong>and</strong> average file size < <code>maxAvgFileSizeMB</code> = 3 MiB</td><td><code>warning</code></td></tr><tr><td>Broadcast sizing: missed (origin dataflint <code>JoinToBroadcastAlert</code>)</td><td>a 2-child <code>SortMergeJoin</code> whose smaller side is < 10 MiB (unconditional), or < 100 MiB with the larger side > 10 GiB, or < 1 GiB with larger > 300 GiB, or < 5 GiB with larger > 1 TiB (<code>broadcastTiers</code> × <code>comparisonTiers</code>)</td><td><code>info</code></td></tr><tr><td>Broadcast sizing: oversized (origin dataflint <code>BroadcastTooLargeAlert</code>)</td><td>a <code>BroadcastExchange</code> node whose <code>data size</code> metric > <code>overBroadcastBytes</code> = 1 GiB</td><td><code>warning</code></td></tr></tbody></table><h4 id="spill-magnitude-tiers" tabindex="-1">Spill magnitude tiers <a class="header-anchor" href="#spill-magnitude-tiers" aria-label="Permalink to "Spill magnitude tiers""></a></h4><p>The spill row's band is a fixed <code>warning</code> fallback, but <code>computeSpillMagnitude</code> (<code>src/detectors.ts</code>) still runs on every spill finding and sets its <code>spillMagnitude</code> field, which the Spill widget displays. Its tiers, in evaluation order (first match wins, <code>null</code> when nothing matches):</p><table tabindex="0"><thead><tr><th>Condition</th><th>Magnitude</th></tr></thead><tbody><tr><td>Single-task stage: <code>spillDiskMax ≥ singleTaskDiskGiB</code> = 1 GiB <strong>or</strong> <code>spillMemMax ≥ singleTaskMemGiB</code> = 4 GiB</td><td><code>severe</code></td></tr><tr><td>Multi-task stage: <code>spillDiskMax ≥ highDiskGiB</code> = 1 GiB, <strong>or</strong> <code>spillDiskMax / taskCount ≥ highTaskDiskMB</code> = 512 MiB (per-task proxy), <strong>or</strong> <code>spillMemMax ≥ highMemGiB</code> = 4 GiB</td><td><code>high</code></td></tr><tr><td>Multi-task stage: <code>spillDiskMax ≥ medDiskMB</code> = 256 MiB <strong>or</strong> <code>spillMemMax ≥ medMemGiB</code> = 1 GiB</td><td><code>medium</code></td></tr><tr><td>Skew (<code>taskCount ≥ skewMinTasks</code> = 10): <code>spillDiskMax / spillDiskP50 > skewRatio</code> = 5× <strong>and</strong> <code>spillDiskMax ≥ skewDiskFloorMB</code> = 128 MiB</td><td><code>high</code></td></tr><tr><td>Skew (<code>taskCount ≥ 10</code>): <code>spillMemMax / spillMemP50 > 5×</code> <strong>and</strong> <code>spillMemMax ≥ skewMemFloorMB</code> = 256 MiB</td><td><code>medium</code></td></tr></tbody></table><p>Disk spill is weighted worse than memory spill by design: the memory thresholds sit well above their disk counterparts at every tier.</p><h3 id="full-threshold-table-never-wallclock-derived" tabindex="-1">Full threshold table (never wallClock-derived) <a class="header-anchor" href="#full-threshold-table-never-wallclock-derived" aria-label="Permalink to "Full threshold table (never wallClock-derived)""></a></h3><table tabindex="0"><thead><tr><th>Rule</th><th>Warning</th><th>Critical</th></tr></thead><tbody><tr><td>Stage shape: PRatio (origin SparkLens <code>StageSkewAnalyzer</code>)</td><td><code>taskCount / totalCores < 0.5</code> (info, under-parallelized)</td><td>none</td></tr><tr><td>Stage shape: OIRatio (origin SparkLens)</td><td><code>outputBytes / inputBytes > 10×</code> (info, data explosion)</td><td>none</td></tr><tr><td>Stage shape: TaskStageSkew (origin SparkLens)</td><td><code>taskDurationMax / stageDuration > 3×</code> (info)</td><td>none</td></tr><tr><td>Failed tasks</td><td>failure rate > 5% (min 10 tasks)</td><td>> 20%</td></tr><tr><td>Stage failed outright (origin sparkDoctor <code>FailureDetector</code>)</td><td>none</td><td>any <code>stageFailureReason</code> present</td></tr><tr><td>Slow host: multi-dimensional (origin Dr. Elephant <code>ExecutorsHeuristic</code>)</td><td>max/median ratio across taskTime/inputBytes/shuffleBytes/storageMemory ≥ 1.33× (info); each dimension's sample must also clear an absolute floor (1000 ms for taskTime, 64 MiB for the byte dimensions)</td><td>≥ 3.16× warning, ≥ 10× critical</td></tr><tr><td>Utilization</td><td>avg active executors / peak < 60% (info)</td><td>none</td></tr><tr><td>Autoscaling churn: short-lived executors (design spike, unvalidated thresholds)</td><td>> 30% of executors alive under 2 min (min 5 executors)</td><td>> 60%</td></tr><tr><td>Job failure rate (origin Dr. Elephant)</td><td>≥ 30% (≥ 10% info)</td><td>≥ 50%</td></tr><tr><td>Idle cores (dataflint)</td><td>busy-core-time / (peak cores × wall-clock) idle > 50% (warning)</td><td>none</td></tr><tr><td>Memory band (dataflint)</td><td>peak heap / allocated > 95% too-small (warning); < 70% over-provisioned (info)</td><td>none</td></tr><tr><td>Caching opportunity (origin Sparklint)</td><td>RDD read across ≥3 stages without <code>.persist()</code></td><td>none (single tier, info)</td></tr><tr><td>Cache utilization: partial caching (this repo)</td><td><code>numCachedPartitions / numPartitions < 0.90</code> (info)</td><td><code>< 0.50</code> (warning)</td></tr><tr><td>Cache utilization: disk spillover (this repo)</td><td><code>diskSize / (memorySize + diskSize) > 0.15</code> (info), <code>MEMORY_AND_DISK*</code> only</td><td><code>> 0.40</code> (warning)</td></tr></tbody></table><p>Spill classification: ≥80% tasks with zero spill → <code>skew</code>; <20% zero → <code>volume</code>; else <code>unclassified</code>. The classification badge is always shown in both compact and expanded spill widget states. This is independent of the magnitude tiers above: classification says <em>what kind</em> of spill, magnitude says <em>how much</em>.</p>',33)])])}const g=t(r,[["render",i]]);export{p as __pageData,g as default};
|
package/public/docs/assets/contributor-guide_architecture_detector-contract.md.DQyCHQNM.lean.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as t,o,c as d,a2 as a}from"./chunks/framework.CNDaxUod.js";const p=JSON.parse('{"title":"Detector contract","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/detector-contract.md","filePath":"contributor-guide/architecture/detector-contract.md"}'),r={name:"contributor-guide/architecture/detector-contract.md"};function i(c,e,s,n,l,h){return o(),d("div",null,[...e[0]||(e[0]=[a("",33)])])}const g=t(r,[["render",i]]);export{p as __pageData,g as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as o,o as t,c as a,a2 as d}from"./chunks/framework.CNDaxUod.js";const g=JSON.parse('{"title":"Drill-down","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/drill-down.md","filePath":"contributor-guide/architecture/drill-down.md"}'),s={name:"contributor-guide/architecture/drill-down.md"};function n(c,e,r,i,l,h){return t(),a("div",null,[...e[0]||(e[0]=[d('<h1 id="drill-down" tabindex="-1">Drill-down <a class="header-anchor" href="#drill-down" aria-label="Permalink to "Drill-down""></a></h1><p><code>StageDetailProvider</code> (<code>src/view/StageDetailContext.tsx</code>) replaces the legacy <code>openStageDetail</code> <code>window</code> <code>CustomEvent</code> with React context. Any component calls <code>useStageDetail().openStage(stageId)</code> to open <code>StageDetailDialog.tsx</code>, a Radix/shadcn <code>Dialog</code>, for that stage. The callers are StagePill, Timeline, StageTable, and TaskSkew's per-stage rows.</p><p>The legacy <code>drillDownToStage</code> force-expand-and-<code>scrollIntoView</code> event has no replacement. No code ever dispatched it, so it was dormant even before the migration, and it was dropped.</p><h2 id="plan-dot-serialization" tabindex="-1">Plan DOT serialization <a class="header-anchor" href="#plan-dot-serialization" aria-label="Permalink to "Plan DOT serialization""></a></h2><p><code>src/plan-dot.ts</code> (entry <code>planTreeToDot(planTree, { title })</code>, origin sparkDoctor's <code>SqlPlanDotWriter</code>) serializes a resolved <code>planTree</code> to a Graphviz DOT string. It is dependency-free string building. A first pass walks the tree assigning stable node ids and <code>label</code> (name, plus <code>detail</code> on a second line when it differs). A second pass emits the parent→child edges once ids are known. The graph is laid out <code>rankdir=BT</code>, leaves at the bottom, matching Spark's own plan orientation.</p><p>It carries no metric annotation: pure structure, matching sparkDoctor's own deferred roadmap item. A null plan returns an empty string. There is no download/export UI for this output anymore (removed); <code>PlanView.tsx</code> calls it only to decide whether a stage's plan tree can render as a graph at all, and a non-empty result gates the "View plan graph" button.</p><h2 id="plan-graph-view" tabindex="-1">Plan graph view <a class="header-anchor" href="#plan-graph-view" aria-label="Permalink to "Plan graph view""></a></h2><p><code>buildPlanGraphModel(planTree, opts)</code> (<code>src/plan-graph-model.ts</code>) flattens a resolved <code>planTree</code> into a <code>{ nodes, edges, segmentIndex, segmentCount, scope, segmentStageIds }</code> graph shape. <code>src/view/PlanGraphRoute.tsx</code> renders it with <code>@xyflow/react</code> (React Flow, pan/zoom/viewport/MiniMap) and <code>@dagrejs/dagre</code> (node layout, <code>src/view/plan-graph/dagre-layout.ts</code>, <code>rankdir: 'RL'</code>).</p><p>Every <code>PlanGraphEdge</code> points <code>source: parentId, target: id</code> (parent/consumer → child/producer), and dagre places an edge's source at the higher-rank end. So <code>RL</code>, rather than the more intuitive-looking <code>LR</code>, is what lands reads/scans (targets, computed first) on the left and the final write/root (source) on the right, matching the left-to-right reading order of the plan's data flow. Each node's React Flow <code>Handle</code>s follow the same horizontal routing: <code>type="target"</code> on <code>Position.Right</code> (its parent sits to the right) and <code>type="source"</code> on <code>Position.Left</code> (its children sit to the left), rather than the top/bottom anchors a vertical <code>TB</code>/<code>BT</code> layout would use.</p><p><code>PlanGraphNode.tsx</code> renders every node at a fixed <code>NODE_WIDTH × NODE_HEIGHT</code> (220×90, also what dagre lays the graph out around) with <code>truncate</code>/<code>title</code> on every text field. An operator with an unusually long label/detail/metric string can't inflate the box and overlap neighbors; it ellipsizes instead, full text on hover.</p><p>Every <code>Exchange</code>-classified node is split into paired write/read halves sharing a <code>sourceNodeId</code>. The write half is grouped with its children's producer component; the read half is grouped with its parent's consumer component and carries <code>buildDurationMap</code>'s duration share. The share stays on the original source Exchange node's attributed/read-side component rather than moving across the cut to the write half.</p><p>Default scope is <code>segment</code>: one Exchange-bounded slice of the plan, resolved via <code>computeSegments</code>/<code>zipSegmentsToStages</code> (<code>src/plan-duration-attribution.ts</code>, shared with <code>attributeStageDurationToPlan</code>). <code>computeSegments</code> gives every connected component a stable pre-order identity plus separate parent/depth/traversal metadata. The numeric identity does not encode Exchange-ancestor depth: <code>zipSegmentsToStages</code> orders by the explicit depth metadata (deepest first, plan traversal order for ties), and the display-only fallback uses component-tree edge distance to find the nearest strictly paired component.</p><p>The view has an opt-in "Expand to full plan" toggle gated by a 300-node confirmation dialog (<code>ExpandConfirmDialog.tsx</code>). A segment-lookup failure that would otherwise render an unguarded full plan is routed through the same guardrail rather than bypassing it. That same failure can also resolve to a full-scope model <em>below</em> the guardrail threshold, rendering immediately with no dialog and no <code>requestedScope</code> change. Since it's a permanent property of that stage's plan (<code>buildPlanGraphModel</code> is deterministic per <code>(planTree, stageId, appModel)</code>), there is no segment view left for that stage to switch back to. So the toggle, still correctly labeled "Back to segment view" per <code>model?.scope</code>, renders <code>disabled</code> rather than silently no-opping on click. Only the explicit-expand path (<code>requestedScope === 'full'</code>) leaves it enabled.</p><p>Findings are never attached to individual plan nodes. No finding type in <code>src/detectors.ts</code> carries an unambiguous pointer to one specific plan-tree node: the closest, <code>smallFiles</code>/<code>duplicatePlanSubtree</code>, only carry a bare operator name string, ambiguous whenever it recurs (guaranteed for <code>duplicatePlanSubtree</code>, which matches a repeated structure by definition). Findings render at the stage level instead, which is always known precisely (<code>Finding.stageId</code>/<code>Finding.stageIds</code>).</p><p>The segment-level group box (<code>PlanGraphSegmentGroupNode.tsx</code>) always renders, even in the default single-stage view where it's the only box on screen. It is headered with its stage id (via <code>segmentStageIds</code>) and a duration chip, or an em-dash placeholder when the segment has no attributed duration. It also carries one <code>TagBadge</code> per finding on this stage, but only when there's no outer stage box to carry them instead (i.e. only in the default single-stage view).</p><p>The expanded full-plan view draws two nested layers of background group boxes (Dagre <code>compound: true</code> for spacing, <code>computeGroupBounds</code> for both): the same inner segment box described above, plus an outer solid tinted container per distinct stage id (<code>PlanGraphStageGroupNode.tsx</code>, <code>zIndex: -2</code> vs the segment layer's <code>-1</code>) merging every segment zipped to that stage, so a stage split across several Exchange-bounded segments still reads as one unit of work. In this expanded view the outer stage box, not the nested segment box, carries the stage's finding chips, since findings are stage-scoped rather than segment-scoped. Each stage box shows only findings matched to its own stage through <code>Finding.stageId</code> or <code>Finding.stageIds</code>; the default segment scope uses the same matching for its displayed stage.</p><p>The outer layer's own corner tag is positioned opposite the segment box's top-left header, because a stage with just one segment (the common case) would otherwise show the same "Stage N" header twice, stacked. <code>STAGE_GROUP_PADDING_Y</code> (64, no reserved header height) is sized to strictly exceed the segment layer's own reserved offset (<code>GROUP_PADDING + GROUP_HEADER_HEIGHT</code> = 56 at the top), while <code>STAGE_GROUP_PADDING_X</code> (32) is kept close to <code>GROUP_PADDING</code>'s own 24px floor so the outer box doesn't visibly balloon sideways. Both still strictly contain the outer box around its nested segment box(es).</p><p>The view is reached via a "View plan graph" button in <code>PlanView.tsx</code>'s toolbar, gated by the same <code>if (dot)</code> check described in "Plan DOT serialization" above. It opens a <code>planGraph: { active, stageId }</code> Zustand slice (<code>openPlanGraph</code>/<code>closePlanGraph</code>, <code>src/store/store.ts</code>) driving a top-level <code>AppRoutes</code> branch in <code>src/App.tsx</code>, modeled directly on the existing <code>comparison</code>/<code>RunComparisonRoute</code> full-takeover pattern.</p><p><code>buildPlanGraphModel</code>'s output is memoized per <code>(activeFileId, stageId, scope)</code> in <code>PlanGraphRoute.tsx</code>, since <code>stageId</code> alone isn't unique across loaded runs and <code>applySnapshot</code> mutates <code>appModel</code> in place rather than replacing it (see <a href="./state-and-history.html#state-model">State model</a>). The memo cache is a module-level <code>Map</code>, so it survives across route open/close: re-opening the same stage in the same run reuses the cached model instead of rebuilding it. It must still be evicted on a fresh parse or reload. <code>resetModel()</code> (<code>store.ts</code>) bumps a <code>modelResetCount</code> counter for exactly this purpose, and <code>PlanGraphRoute.tsx</code> subscribes to it to clear the cache. A counter rather than a direct call, since <code>store.ts</code> has no view-layer imports anywhere else and importing a <code>.tsx</code> module there would invert that dependency direction.</p><h2 id="disclosure-hierarchy" tabindex="-1">Disclosure hierarchy <a class="header-anchor" href="#disclosure-hierarchy" aria-label="Permalink to "Disclosure hierarchy""></a></h2><p>The Summary/Context/Details 3-tier framing (collapsed lead metric → expanded widget body → per-stage <code>StageDetailDialog</code>) does not apply uniformly across the 15 registry widgets (<code>src/view/detector-registry.tsx</code>). 7 have a stage-anchored Details tier reachable via <code>StagePill</code>/<code>StagePillGroup</code>: TaskSkew, ShuffleIO, Spill, GcPressure, Failures, ExecutorTimeline, CachingOpportunity. The other 8 are app/sql-scope with no stage to drill into, by design, so they stop at Summary/Context: MemoryUtilization, JobFailures, ConfigAudit, PlanFindings, CacheUtilization, CoreUsageArea, AutoscalingChurn, IncompleteRun.</p><p>The 3-tier hierarchy is therefore not mandatory for every widget, and the current per-widget shape stays as-is: 2 tiers where there's no stage anchor, 3 where there is.</p><h2 id="reference-panel" tabindex="-1">Reference panel <a class="header-anchor" href="#reference-panel" aria-label="Permalink to "Reference panel""></a></h2><p>The topbar's "Reference" button and <code>DocsLink</code> (<code>src/view/DocsContext.tsx</code>) open a shadcn <code>Sheet</code> (<code>src/view/DocsSheet.tsx</code>, mounted once inside <code>DocsProvider</code>/<code>Dashboard.tsx</code>) that iframes the vendored, prebuilt docs site (<code>vendor/spark-doc/index.html</code>, referenced via <code>docs-config.js</code>'s <code>docsUrl()</code>). <code>useDocs().open(anchor)</code> sets React state (<code>isOpen</code>, <code>anchor</code>); Radix/Base UI's <code>Sheet</code> owns the slide-in animation, focus trap, and outside-click/Escape dismissal. No more hand-rolled <code>.layout--split</code> class plus width-shrink CSS.</p><p>The vendored <code>index.html</code> is still the built artifact the upstream <code>shuffle-works/spark-tuning-reference</code> repo commits alongside its Markdown source; refresh it with <code>npm run update-docs</code>, unaffected by the view migration (see <code>scripts/update-docs.mjs</code>). Production builds copy the site to <code>dist/vendor/spark-doc</code>; Vite's relative asset base keeps the app and docs usable when <code>dist/</code> is deployed under a URL subpath.</p><p><code>npm run update-docs</code> vendors upstream's <code>anchors.json</code> too (its SDOC-020 projection of the doc-anchor manifest) to <code>vendor/spark-doc/anchors.json</code>. <code>tests/doc-anchor-coverage.test.js</code> intersects it against every detector's <code>docAnchor</code> and warns (never fails) on dead links (detector points at an anchor <code>anchors.json</code> doesn't have) or orphaned Detector Catalog anchors (no detector points at them); see <code>scripts/doc-anchor-coverage.js</code>.</p><p><code>npm run update-docs</code> also vendors upstream's other two build outputs, <code>meta.html</code> ("how this site works") and <code>landing.html</code> (the symptom-picker entry page), to <code>vendor/spark-doc/meta.html</code> and <code>vendor/spark-doc/landing.html</code>. Neither carries an anchor the app links into, so both get the same fetch/diff/write treatment as <code>index.html</code> minus its anchor-coverage gate: a plain looks-like-HTML check before overwriting the vendored copy.</p><p><code>DocsSheet.tsx</code> re-implements the legacy <code>docs-panel.js</code> handshake as React effects. A fresh <code>open()</code> sets the iframe's <code>src</code> (full load); re-navigating while already open posts a <code>{type:'navigate', anchor}</code> message instead (<code>iframe.contentWindow.postMessage</code>, origin <code>'*'</code>, for the same reason as before: <code>file://</code>'s iframe origin is opaque) so the doc app updates without a reload or lost scroll position. The doc app posts <code>{type:'ready'}</code> once its own listener is wired up. <code>DocsSheet</code> only starts posting <code>{type:'theme', theme}</code> after that handshake, since posting before <code>ready</code> would race the iframe's message listener setup, and re-posts on every theme toggle while open. Unrecognized inbound message types are ignored.</p><p>Every message on this channel also carries a <code>tok</code>: a per-open handshake nonce (<code>DocsSheet</code>'s <code>genTok</code>, default <code>crypto.randomUUID()</code>), generated fresh on each new <code>open()</code> and reused across re-navigations within that same open. It rides in the iframe <code>src</code>'s query string (<code>?tok=...</code>, not the <code>#anchor</code> hash), is echoed on every outgoing <code>navigate</code>/<code>theme</code> message, and must match on the inbound <code>ready</code> before <code>DocsSheet</code> trusts it. A mismatched or missing <code>tok</code> is ignored exactly like an unrecognized message type. This is the app-side half of spark-doc's SDOC-021 <code>bridge.js</code> hardening: the iframe origin is opaque, so the nonce, not an origin allowlist, is what makes posting to <code>'*'</code> safe. <code>bridge.js</code> keeps a transitional fallback for a missing <code>tok</code>; <code>DocsSheet</code> has none, and always generates and validates one.</p>',29)])])}const u=o(s,[["render",n]]);export{g as __pageData,u as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as o,o as t,c as a,a2 as d}from"./chunks/framework.CNDaxUod.js";const g=JSON.parse('{"title":"Drill-down","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/drill-down.md","filePath":"contributor-guide/architecture/drill-down.md"}'),s={name:"contributor-guide/architecture/drill-down.md"};function n(c,e,r,i,l,h){return t(),a("div",null,[...e[0]||(e[0]=[d("",29)])])}const u=o(s,[["render",n]]);export{g as __pageData,u as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as t,o,c as d,a2 as a}from"./chunks/framework.CNDaxUod.js";const p=JSON.parse('{"title":"Impact estimation","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/impact-estimation.md","filePath":"contributor-guide/architecture/impact-estimation.md"}'),c={name:"contributor-guide/architecture/impact-estimation.md"};function s(i,e,n,r,l,u){return o(),d("div",null,[...e[0]||(e[0]=[a('<h1 id="impact-estimation" tabindex="-1">Impact estimation <a class="header-anchor" href="#impact-estimation" aria-label="Permalink to "Impact estimation""></a></h1><p>Every finding covered by this section carries an optional <code>impactEstimate: {basis, wallClock, estimateMethod, rawWaste?}</code> (<code>src/types.ts</code>), attached by <code>src/impact-estimator.ts</code> as a post-pass after <code>DETECTORS</code> finishes (<code>src/analyzer.ts</code>). <code>estimateMethod</code> ('measured' | 'modeled' | 'none') is a distinct axis from the per-finding <code>confidence</code> field (<a href="./board-widgets.html#confidence-metadata">Confidence metadata</a>): <code>confidence</code> says how much to trust the finding itself, <code>estimateMethod</code> says how its impact number was derived. <code>'none'</code> marks a purely informational finding with no waste model at all (<code>configAudit</code>, <code>stageFailed</code>, <code>failures</code>, <code>incompleteRun</code>, <code>slowHost</code>'s byte-dimension multiDim shapes): it's distinct from <code>'measured'</code>/<code>'modeled'</code>, which both attach a real (if approximate) formula. <code>basis</code> is one of:</p><ul><li><code>'serial'</code>: the tied stage ran (effectively) alone; <code>wallClock</code> is a near-point estimate, <code>low === high</code>.</li><li><code>'contended'</code>: the tied stage shared wall-clock time with others; <code>wallClock</code> is an honest range, <code>high</code> optimistic (assumes the fix could still fully land), <code>low</code> the guaranteed floor.</li><li><code>'resourceOnly'</code>: no wall-clock claim is defensible (not stage-tied by nature, or the stage was excluded from the occupancy sweep), <code>wallClock: null</code>, but the formula's real signal survives in <code>rawWaste</code>.</li><li><code>'informational'</code>: no quantifiable magnitude at all, <code>wallClock: null</code>, no <code>rawWaste</code>.</li></ul><p><code>{basis: 'resourceOnly'|'informational', wallClock: null}</code> replaced the earlier design's <code>{low: 0, high: 0}</code>: that single value used to mean two incompatible things ("provably no wall-clock cost" and "the model gave up"), and 76-97% of stage-tied findings on real logs were the second case wearing the first case's clothing (2026-08-30 N1 redesign; see <code>docs/superpowers/specs/2026-08-30-critical-path-occupancy-redesign.md</code>).</p><p><code>rawWaste</code> is not exclusive to <code>resourceOnly</code>/<code>informational</code> findings: every <code>serial</code>/ <code>contended</code> finding carries it too (the only exceptions are <code>coldStart</code>, whose <code>wallClock</code> figure is already unclipped, and <code>estimateMethod: 'none'</code> findings, which have no formula at all), holding the formula's pre-clip magnitude in the formula's own natural unit (ms, bytes or core-ms). <code>wallClock</code> is what the occupancy model says is recoverable, which clips against the stage's own physical floor; <code>rawWaste</code> is what the stage really wasted either way. The two answer different questions.</p><h2 id="occupancy-weighted-attribution" tabindex="-1">Occupancy-weighted attribution <a class="header-anchor" href="#occupancy-weighted-attribution" aria-label="Permalink to "Occupancy-weighted attribution""></a></h2><p><code>src/occupancy.ts</code> sweeps every stage's observed <code>[submittedAt, completedAt)</code> window and splits each instant's wall-clock among concurrently-active stages proportional to <code>coreWeight(S) = stage.executorRunTime / stageDurationMs</code> (an average-concurrency proxy, held constant across the stage's whole window: this codebase has no per-task timestamps outside the parser worker to do better). Summing a stage's share across its own window gives its <code>occupancy(S)</code>; <code>gate(S) = occupancy(S) / duration(S) ∈ [0, 1]</code> is the single number that replaces the old CPM model's <code>onCriticalPath</code>/<code>slackMs</code>/<code>isUniquelyCritical</code>. 1.0 means the stage ran completely alone; 0 means the stage had zero <code>executorRunTime</code> while overlapping other, positive-weight stages, so it got no share of the shared window. Stages with <code>duration(S) <= 0</code> (Spark-skipped stages, or a malformed <code>submittedAt === completedAt</code>) are excluded from the sweep entirely.</p><p>This mechanism replaced a CPM (critical-path-method) graph over <code>parentIds</code> that produced near-zero on-critical-path membership on real logs (0.1-10.6% of stages, max graph depth 2 on 5 of 6 real logs measured): <code>parentIds</code> alone is too sparse a precedence signal for a meaningful longest-path computation. The same degeneracy fed <code>efficiency-model.ts</code>'s <code>floorInfiniteMs</code> ("floor with infinite executors"); rather than leave a second, unreconciled critical-path number in the codebase for a future UI to display next to the occupancy-based figures above, <code>criticalPathMs()</code>/<code>CriticalPathStage</code> (embedded in <code>efficiency-model.ts</code>, never a standalone module) were removed outright (no occupancy-based replacement: occupancy apportions observed concurrent time, it doesn't compute a dependency-graph longest path, so there's no drop-in equivalent). <code>efficiency-model.ts</code> now reports only <code>floorZeroSkewMs</code> (total task time / total cores) as its theoretical floor.</p><p><code>ceiling(S) = max(stage.taskDurationMax, stage.executorRunTime / totalCores)</code> is a physical floor on a stage's own duration: bounded below by its single longest task (unsplittable no matter how much parallelism exists) or by its core-work spread across every core in the cluster, whichever is larger. Every waste formula's raw claim is clipped against it before gate-weighting: <code>wasteMs_clipped(S) = min(wasteMs_claimed, max(0, duration(S) - ceiling(S)))</code>, so a finding can never claim to save more than the portion of the stage's observed duration that sits above its own unbeatable floor. This is what fixes historical overclaim bugs (a <code>tinyTask</code> finding claiming 407.5s on a 13.1s stage capped to 8.9s; a <code>shuffle</code> finding claiming 1939.9s on a 991.3s/1688.3s stage capped to 610.3s/250.1s).</p><p><code>analyzer.ts</code> feeds this <code>totalCores</code> from <code>src/core-count.ts</code>'s <code>computePeakConcurrentCores(app, executorsAdded, executorsRemoved)</code>, not the shared <code>computeTotalCores</code> helper. <code>computeTotalCores</code> sums every <code>ExecutorAdded</code> event's cores regardless of overlap, so under dynamic allocation or executor replacement it can far exceed the cores ever actually concurrent, which understates <code>ceiling(S)</code> and lets churn inflate a finding's claimed wall-clock. <code>computePeakConcurrentCores</code> instead sweeps add/remove events by timestamp and tracks the running total's peak, so a churned-through executor's cores are never double-counted against its replacement's. Same-timestamp events tie-break by delta ascending, so a removal applies before a same-instant replacement's addition (otherwise a same-instant swap would momentarily double-count both as concurrent). If every <code>executorsAdded</code> entry lacks <code>totalCores</code> the cores sweep peaks at zero and tells us nothing; the function then falls back to sweeping peak <em>executor count</em> instead (still concurrency-aware, just cores-blind) and multiplies by the configured per-executor core count, rather than falling back to <code>executorsAdded.length × cores</code>, which would reintroduce the exact cumulative-overcount-under-churn bug this function exists to avoid.</p><p>Per-finding estimate, using <code>wasteMs_clipped(S)</code>:</p><ul><li><code>gate(S) >= 0.999</code>: <code>basis: 'serial'</code>, <code>low = high = wasteMs_clipped(S)</code>.</li><li><code>gate(S) < 0.999</code>: <code>basis: 'contended'</code>, <code>high = wasteMs_clipped(S)</code>, <code>low = wasteMs_clipped(S) * gate(S)</code>.</li></ul><p>A finding spanning multiple stages (<code>stageIds</code>, plural) sums each stage's own estimate and caps the joint total at the union of just that finding's own stage windows (via <code>mergeIntervals</code>, <code>src/wall-clock.ts</code>): <code>high = min(Σ high_i, unionMs(stageIds))</code>, <code>low = min(Σ low_i, unionMs(stageIds))</code>. This is what prevents overclaiming when two or more of a finding's stages overlap in wall-clock time: a plain sum-and-cap, no CPM re-simulation. The union cap can force <code>low === high</code> numerically even when the constituent stages were individually contended (e.g. two fully-overlapping stages each at <code>gate</code> 0.5), so <code>basis</code> isn't derived from that numeric equality: a multi-stage finding gets <code>basis: 'serial'</code> only when every one of its per-stage estimates was itself <code>'serial'</code>; otherwise <code>'contended'</code>.</p><p>Regression guard: a finding whose stage (or, for a multi-stage finding, every one of its stages) ran alone (<code>gate >= 0.95</code>, deliberately looser than the <code>0.999</code> "serial" cutoff above: this guard exists to catch egregious false zeros, not to gate which <code>basis</code> a finding gets) with a real underlying magnitude (<code>rawWaste.value > 0</code>) must never report <code>wallClock.high === 0</code> or <code>basis: 'informational'/'resourceOnly'</code>. Covered by <code>tests/impact-estimator-real-log.test.js</code> against a real fixture; relies on <code>rawWaste</code> being attached to every serial/contended-capable formula (see above), so it's blind only to <code>coldStart</code> and the purely informational (<code>estimateMethod: 'none'</code>) finding types.</p><p>Real-log spot-check (2026-08-30, <code>grupo-semanal-beauty-application_1785266278671_91660.zstd</code>): median <code>gate</code> across stages was <code>≈0.34</code> (0.3428060791718594 exactly); <code>collectRun</code> plus the occupancy sweep together took <code>≈6,589</code>ms on the largest fixture measured (<code>run-compare-calimax-candidate-application_1784568768686_119096.zstd</code>, <code>1169</code> stages): parse-dominated, the sweep alone was not isolated by this measurement, but not a magnitude that suggests a regression either. <code>54</code> previously-<code>{0,0}</code> stage-tied findings on stages that ran effectively alone now report a real <code>wallClock</code> range instead.</p><h2 id="cross-finding-rollup-computestageunionms" tabindex="-1">Cross-finding rollup: <code>computeStageUnionMs</code> <a class="header-anchor" href="#cross-finding-rollup-computestageunionms" aria-label="Permalink to "Cross-finding rollup: `computeStageUnionMs`""></a></h2><p>The Findings tab's recommendation rollup (<code>FixTheseFirst.tsx</code>, built from <code>buildRecommendationRollup</code>, <code>src/recommendation-rollup.ts</code>) groups the filtered catalog by detector <code>type</code>, then needs its own cap for a group of several findings of that type, not just one finding's own <code>stageIds</code>. Summing each finding's already-clipped <code>wallClock.high</code> naively double-counts any stage two of those findings both touch. <code>computeStageUnionMs(stageIds, stages)</code> covers this: collect every stage touched by any finding in the group, merge their <code>[submittedAt, completedAt)</code> intervals, and sum the merged intervals' durations, so the group's wall-clock union holds regardless of how many findings' <code>stageIds</code> overlap. Stages missing either bound are skipped rather than defaulted to <code>0</code> (the same filter <code>computeWallClock</code> applies), so a truncated log (the case <code>incompleteRun</code> flags) can't contribute a negative interval and a negative recoverable-time figure.</p><p>It reuses the same <code>mergeIntervals</code> primitive (<code>src/wall-clock.ts</code>) that backs <code>src/occupancy.ts</code>'s per-finding <code>estimateMultiStage</code>/its internal <code>unionMs</code> sum, but is not an extension of that function: <code>estimateMultiStage</code> caps one finding's own multi-stage claim during the impact-estimation pass, before a <code>Finding</code> object even exists; <code>computeStageUnionMs</code> runs later, in the view layer, capping a naive sum <em>across</em> several already-estimated findings that happen to share a detector <code>type</code>. <code>buildTimeGroup</code> (same module) takes the smaller of the naive per-finding sum and this union figure as the group's <code>recoverableMsHigh</code>, falling back to the naive sum untouched when the group's findings carry no stage IDs at all (nothing to union against).</p><p>Within one <code>type</code> group, <code>buildRecommendationRollup</code> splits findings into up to three tiers, always rendered in this fixed order. <code>time</code> covers findings with a real <code>impactEstimate.wallClock</code> (<code>buildTimeGroup</code>, the union-capped figure above). <code>resource</code> covers findings with no <code>wallClock</code> but a <code>rawWaste</code> figure (<code>buildResourceGroup</code>), grouped again by <code>rawWaste.unit</code> so a <code>bytes</code> total never gets summed against a <code>coreHours</code> total under one type. <code>count</code> covers findings with neither (<code>buildCountGroup</code>), a plain per-impact-band tally with no magnitude claim at all. Each <code>RollupGroup</code> also carries its own <code>findings: Finding[]</code> (the exact members that fed the aggregate), which <code>FixTheseFirst.tsx</code> reads directly to pick a group's highest-impact member and to render its expanded, paginated list.</p><p>A type only contributes a tier when it has at least one finding of that kind; most types produce exactly one tier, but a type whose formula varies by <code>variant</code>/<code>rule</code> (e.g. <code>memoryUtilization</code>, see the coverage table below) can produce more than one.</p><p><code>cachingOpportunity</code> and <code>cacheUtilization</code> are both <code>cost-only</code>: <code>basis: 'resourceOnly'</code>, <code>wallClock: null</code>, but <code>rawWaste.unit</code> is <code>'ms'</code>, the same unit a real <code>wallClock</code> figure would use, because their formula's natural output happens to be time (a re-read cost), not because either finding makes a wall-clock claim. Left unlabeled, a <code>resource</code>-tier "ms" total sitting next to a <code>time</code>-tier "recoverable time" total would read as directly comparable when it isn't: the resource figure was never gate-clipped against any stage's occupancy, so it can exceed what the stage actually spent. <code>FixTheseFirst.tsx</code> calls this out via its trailing-stat copy: a <code>resource</code>- kind group (any unit, including <code>ms</code>) always reads "resource-cost projection", never "recoverable", so the two ms-shaped numbers are never mistaken for the same kind of claim.</p><h2 id="per-formula-spot-checks" tabindex="-1">Per-formula spot-checks <a class="header-anchor" href="#per-formula-spot-checks" aria-label="Permalink to "Per-formula spot-checks""></a></h2><table tabindex="0"><thead><tr><th>Detector</th><th>Formula basis</th><th>Spot-check</th></tr></thead><tbody><tr><td>gc</td><td><code>jvmGCTime / (executorRunTime / stageDurationMs)</code></td><td><code>grupo-semanal-beauty-application_1785266278671_91660.zstd</code>, stage 507: <code>jvmGCTime</code>=1080ms, <code>executorRunTime</code>=27509ms, <code>stageDurationMs</code>=56279ms → <code>wasteMs</code> = 1080 / (27509/56279) ≈ 2209.5ms. That's ≈3.9% of the stage's 56.3s wall-clock duration, matching the finding's own reported <code>gcPct</code> (3.9%) exactly, as the formula guarantees by construction. Under the occupancy model this stage's <code>gate</code> is <code>0.041</code> (0.04145044590332269 exactly): <code>basis: 'contended'</code>, <code>wallClock: {low: 91.6, high: 2209.5}</code> (91.5850382272182 / 2209.506706895925 exactly, per the Step 1 script's per-stage output).</td></tr><tr><td>shuffle</td><td><code>shuffleReadBytes / SHUFFLE_THROUGHPUT_BPS</code> (fallback tier; real-metrics parser not yet built)</td><td><code>ventas-mensual-multi-big-application_1785266278671_91510.zstd</code>, stage 99 (<code>SHFL</code> finding): <code>shuffleReadBytes</code>=204,172,518,504 → <code>wasteMs</code> = 204172518504 / 125,000,000 × 1000 ≈ 1,633,380ms, matching <code>rawWaste.value</code> exactly. Eyeball against the timeline: the stage's actual wall-clock duration is only 763,776ms, i.e. this run moved shuffle data at ≈267MB/s, roughly 2x the assumed 125MB/s (1Gbps) constant: expected for the fallback tier's deliberately conservative assumption, but worth knowing the modeled figure runs high on fast-network clusters. Under the occupancy model this stage's <code>gate</code> is <code>1</code>, <code>ceiling</code> is <code>≈434,568.1</code>ms (434568.1041666667 exactly): <code>wallClock</code> capped to <code>≈329,207.9</code>ms (329207.8958333333 exactly), since the raw 1,633,380ms claim vastly exceeds the stage's own 763,776ms duration.</td></tr><tr><td>spill</td><td><code>diskBytesSpilled / SPILL_IO_THROUGHPUT_BPS</code></td><td>Same run and stage (99): <code>diskBytesSpilled</code>=145,978,433,675 (note: the <code>SPILL</code> finding's own <code>value</code>/<code>metric</code> report <code>memoryBytesSpilled</code>=913,686,966,448, ~6x larger; the formula correctly uses the smaller disk figure, not that one) → <code>wasteMs</code> = 145978433675 / 200,000,000 × 1000 ≈ 729,892ms, matching <code>rawWaste.value</code> exactly. Eyeball: that's ≈191MB/s of implied disk throughput against the stage's 763,776ms actual duration, close to the assumed 200MB/s constant. Same ceiling-clip caveat as the shuffle row above applies here too, on the same stage.</td></tr></tbody></table><h2 id="overlap-caveat-skew-straggler" tabindex="-1">Overlap caveat: skew / straggler <a class="header-anchor" href="#overlap-caveat-skew-straggler" aria-label="Permalink to "Overlap caveat: skew / straggler""></a></h2><p><code>skew</code> (small-stage max-P50 fallback branch) and <code>straggler</code> can both fire on the same stage from the same single dominant outlier task, and each is clipped independently. This phase does not dedupe or suppress either: each keeps its own independently-computed <code>wallClock</code>. Do not sum <code>wallClock.high</code> across multiple findings on the same stage: if both fire together, they describe the same underlying waste, not two separate wastes. This overlap caveat is orthogonal to (and compounds with) the ceiling clip above: a stage with one dominant outlier task trips both detectors <em>and</em> has a small <code>ceiling</code>-derived recoverable room, since <code>ceiling</code> is itself <code>>= taskDurationMax</code>, the very quantity these two detectors are reacting to.</p><p><code>stageShape</code>'s <code>taskStageSkew</code> rule no longer participates in this caveat: it reports a <code>resourceOnly</code> idle-core-ms figure (see the coverage table below) instead of a wall-clock claim, so there's nothing left to double-count against <code>skew</code>/<code>straggler</code>. Its trigger condition (<code>taskDurationMax / stageDurationMs > skewWarn</code>) mathematically forces the occupancy-clipped wall-clock estimate to exactly zero on every firing (see <code>src/detectors.ts</code>'s <code>taskStageSkew</code> comment), which is why it was moved off the wall-clock path entirely rather than reconciled against the same ceiling clip as its two siblings above.</p><h2 id="per-finding-type-coverage" tabindex="-1">Per-finding-type coverage <a class="header-anchor" href="#per-finding-type-coverage" aria-label="Permalink to "Per-finding-type coverage""></a></h2><p>One row per distinct <code>type</code> string <code>src/detectors.ts</code> actually emits (cross-checked against <code>computeEstimateForFinding</code>'s <code>case</code> labels in <code>src/impact-estimator.ts</code>, not assumed from the prose here): every row below has a case, so the table itself is the coverage count, not a number restated here. <code>broadcastSizing</code> is a <code>DETECTORS</code> entry label only, and the plan-walk it drives emits <code>overBroadcast</code>/<code>underBroadcast</code> findings instead, so those two are the rows that appear, not <code>broadcastSizing</code> itself. Tag meanings: <code>measured</code> and <code>modeled</code> both produce a real, gate-clipped, non-<code>{0,0}</code><code>wallClock</code> (the difference is whether the formula's inputs are recorded per-stage fields or an assumed constant like a throughput figure); <code>cost-only</code> always reports <code>basis: 'resourceOnly'</code>, <code>wallClock: null</code> but carries its real signal in <code>rawWaste</code>; <code>informational-only</code> reports <code>basis: 'informational'</code>, <code>wallClock: null</code> with no <code>rawWaste</code> at all, since there's nothing quantifiable. A type with more than one tag fires a different formula per <code>variant</code>/<code>rule</code> on the same finding type; the basis column says which.</p><table tabindex="0"><thead><tr><th>Finding type</th><th>Scope</th><th>Tag</th><th>Basis</th></tr></thead><tbody><tr><td><code>retryWaste</code></td><td>stage</td><td>measured</td><td><code>retryWasteMs</code>, gate-clipped; pre-clip figure kept as <code>rawWaste</code> in <code>ms</code></td></tr><tr><td><code>speculationWaste</code></td><td>stage</td><td>measured</td><td><code>speculationWasteMs</code>, gate-clipped; pre-clip figure kept as <code>rawWaste</code> in <code>ms</code></td></tr><tr><td><code>coldStart</code></td><td>app</td><td>measured</td><td><code>gapSeconds × 1000</code>, unclipped, <code>basis: 'serial'</code> unconditionally (a pre-first-task gap can't overlap any stage)</td></tr><tr><td><code>gc</code></td><td>stage</td><td>modeled</td><td><code>jvmGCTime / (executorRunTime / stageDurationMs)</code>, gate-clipped: the concurrency division is an approximation, not a reconstruction, hence <code>modeled</code>; <code>rawWaste</code> in <code>coreMs</code> is the raw <code>jvmGCTime</code> sum before that conversion</td></tr><tr><td><code>skew</code></td><td>stage</td><td>measured</td><td><code>taskDurationP95</code> or <code>Max</code> minus <code>P50</code> (per <code>metric</code>), gate-clipped; pre-clip figure kept as <code>rawWaste</code> in <code>ms</code></td></tr><tr><td><code>straggler</code></td><td>stage</td><td>measured</td><td><code>taskDurationMax − taskDurationP50</code>, gate-clipped</td></tr><tr><td><code>stageShape</code></td><td>stage</td><td>cost-only</td><td>all three rules are <code>estimateMethod: 'measured'</code>, real per-stage fields, no assumed constant: <code>'lowParallelism'</code> → <code>rawWaste</code> in <code>coreMs</code> (idle cores × stage duration); <code>'dataExplosion'</code> → <code>rawWaste</code> in <code>bytes</code> (<code>outputBytes − inputBytes</code>); <code>'taskStageSkew'</code> → <code>rawWaste</code> in <code>coreMs</code> (<code>max(0, min(totalCores, taskCount) − 1) × (taskDurationMax − taskDurationP50)</code>, the cores idle during the straggler's tail at achieved concurrency)</td></tr><tr><td><code>slowHost</code></td><td>stage</td><td>measured / informational-only</td><td>duration-based variants (<code>hostMeanRatio</code>, <code>durationShare</code>, <code>multiDim</code>+<code>taskTime</code>): <code>value − taskDurationP50</code>, gate-clipped; byte-based <code>multiDim</code> dimensions: no formula yet</td></tr><tr><td><code>duplicatePlanSubtree</code></td><td>sql</td><td>measured</td><td>each contributing stage's real wall-clock duration × the redundant fraction <code>(occurrences − 1) / occurrences</code>, summed and capped at the finding's own <code>stageIds</code> union. <code>stageIds</code> is narrowed to the stages that actually ran the duplicated subtree's matched node instances (accumulator-ID evidence resolved onto each <code>PlanNode</code> at parse time, see <a href="./detector-contract.html#stage-id-attribution-for-plan-advisor-findings">Stage-ID attribution for Plan Advisor findings</a>), falling back to the whole execution's stages only when no matched instance has any accumulator coverage</td></tr><tr><td><code>shuffle</code></td><td>stage</td><td>modeled</td><td><code>shuffleReadBytes / SHUFFLE_THROUGHPUT_BPS</code> (assumed ~125MB/s), gate-clipped; <code>rawWaste</code> in <code>bytes</code> is the measured <code>shuffleReadBytes</code> behind it</td></tr><tr><td><code>spill</code></td><td>stage</td><td>modeled</td><td><code>diskBytesSpilled / SPILL_IO_THROUGHPUT_BPS</code> (assumed ~200MB/s), gate-clipped; <code>rawWaste</code> in <code>bytes</code> is <code>diskBytesSpilled</code>, which is the number the formula uses and not the <code>memoryBytesSpilled</code> the finding's own <code>metric</code> displays</td></tr><tr><td><code>stageSlowness</code></td><td>stage</td><td>modeled</td><td>stage duration minus the <code>stageSlowness</code> detector's own <code>infoMin</code> threshold, gate-clipped</td></tr><tr><td><code>partitionSizing</code></td><td>stage</td><td>modeled</td><td><code>maxPartitionTooBig</code>/<code>shufflePartitionSkew</code>: shuffle-throughput formulas, gate-clipped. <code>lowShuffleParallelism</code>: stage duration scaled down by the shortfall between actual and ideal-partition-count task counts (<code>stageDurationMs × (1 − taskCount / targetTaskCount)</code>), i.e. the serialized work more partitions would let run concurrently, not the scheduling cost of the tasks you'd add to fix it</td></tr><tr><td><code>tinyTask</code></td><td>stage</td><td>modeled</td><td>excess task count over 10% of the stage's actual count, × assumed per-task scheduling overhead, gate-clipped; pre-clip figure kept as <code>rawWaste</code> in <code>ms</code></td></tr><tr><td><code>smallFiles</code></td><td>sql</td><td>modeled / cost-only</td><td><code>excessFileCount × FILE_OPEN_OVERHEAD_MS</code>, summed and capped over <code>stageIds</code>'s union; with no <code>stageIds</code> to map to, cost-only with that same figure as <code>rawWaste</code> in <code>ms</code>. <code>stageIds</code> is narrowed the same way (see <a href="./detector-contract.html#stage-id-attribution-for-plan-advisor-findings">Stage-ID attribution for Plan Advisor findings</a>); falls back to the whole execution's stages when the flagged node(s) have no accumulator coverage.</td></tr><tr><td><code>overBroadcast</code></td><td>sql</td><td>modeled / cost-only</td><td><code>broadcastBytes / BROADCAST_BANDWIDTH_BPS</code>, summed and capped over <code>stageIds</code>'s union; cost-only with <code>rawWaste</code> in <code>ms</code> when not stage-mappable. <code>stageIds</code> is narrowed the same way; falls back to the whole execution's stages when the flagged node(s) have no accumulator coverage.</td></tr><tr><td><code>underBroadcast</code></td><td>sql</td><td>modeled / cost-only</td><td><code>smallerSideBytes / BROADCAST_BANDWIDTH_BPS</code>, summed and capped over <code>stageIds</code>'s union; cost-only with <code>rawWaste</code> in <code>ms</code> when not stage-mappable. <code>stageIds</code> is narrowed the same way; falls back to the whole execution's stages when the flagged node(s) have no accumulator coverage.</td></tr><tr><td><code>memoryUtilization</code></td><td>app</td><td>cost-only / informational-only</td><td>Three of the four variants report <code>rawWaste</code> in <code>mbSeconds</code>: <code>variant: 'wasteModel'</code> passes through its own <code>wastedMBSeconds</code>; <code>'idleCores'</code> uses <code>idleRateFraction × allocatedMB × peakExecutors × appDurationSeconds</code>; <code>'memoryBand'</code> with <code>rule: 'heapOverProvisioned'</code> uses <code>(allocatedBytes − heap) in MB × appDurationSeconds</code>. <code>'memoryBand'</code> with <code>rule: 'heapNearCapacity'</code> is an OOM-risk signal rather than a waste, and the <code>dataUnavailable</code> shape has no inputs at all: both informational-only</td></tr><tr><td><code>utilization</code></td><td>app</td><td>cost-only</td><td><code>rawWaste</code> in <code>coreHours</code>: <code>(1 − utilizationFraction) × appDurationMs × totalCores / 3.6e6</code></td></tr><tr><td><code>coreLocality</code></td><td>app</td><td>cost-only</td><td><code>rawWaste</code> in <code>coreMs</code>: <code>nonLocalTaskCount × NETWORK_FETCH_PENALTY_MS</code></td></tr><tr><td><code>autoscalingChurn</code></td><td>app</td><td>cost-only</td><td><code>rawWaste</code> in <code>coreHours</code>: <code>shortLivedExecutorCount × EXECUTOR_STARTUP_OVERHEAD_MS / 3.6e6</code></td></tr><tr><td><code>configAudit</code></td><td>config</td><td>informational-only</td><td>a config-drift check standing alone; no waste formula</td></tr><tr><td><code>jobFailureRate</code></td><td>app</td><td>cost-only</td><td><code>rawWaste</code> in <code>coreHours</code>: <code>failedJobCount × avgJobDurationMs / 3.6e6</code></td></tr><tr><td><code>cachingOpportunity</code></td><td>app</td><td>cost-only</td><td><code>rawWaste</code> in <code>ms</code>: <code>totalReadBytes / RE_READ_THROUGHPUT_BPS</code></td></tr><tr><td><code>cacheUtilization</code></td><td>app</td><td>cost-only</td><td><code>rawWaste</code> in <code>ms</code>: uncached-or-spilled bytes <code>/ RE_READ_THROUGHPUT_BPS</code>, where the never-cached partitions' bytes are extrapolated from the cached partitions' own average size (<code>memorySize + diskSize</code>, over <code>numCachedPartitions</code>), plus <code>diskSize</code> again for the already-cached-but-on-disk partitions' own re-read cost</td></tr><tr><td><code>stageFailed</code></td><td>stage</td><td>informational-only</td><td>no waste formula</td></tr><tr><td><code>failures</code></td><td>stage</td><td>informational-only</td><td>no waste formula</td></tr><tr><td><code>incompleteRun</code></td><td>app</td><td>informational-only</td><td>no waste formula</td></tr></tbody></table>',29)])])}const g=t(c,[["render",s]]);export{p as __pageData,g as default};
|
package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.CzlXOnCq.lean.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as t,o,c as d,a2 as a}from"./chunks/framework.CNDaxUod.js";const p=JSON.parse('{"title":"Impact estimation","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/impact-estimation.md","filePath":"contributor-guide/architecture/impact-estimation.md"}'),c={name:"contributor-guide/architecture/impact-estimation.md"};function s(i,e,n,r,l,u){return o(),d("div",null,[...e[0]||(e[0]=[a("",29)])])}const g=t(c,[["render",s]]);export{p as __pageData,g as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as t,o as r,c as a,a2 as i}from"./chunks/framework.CNDaxUod.js";const m=JSON.parse('{"title":"Architecture","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/index.md","filePath":"contributor-guide/architecture/index.md"}'),c={name:"contributor-guide/architecture/index.md"};function o(n,e,s,h,d,l){return r(),a("div",null,[...e[0]||(e[0]=[i('<h1 id="architecture" tabindex="-1">Architecture <a class="header-anchor" href="#architecture" aria-label="Permalink to "Architecture""></a></h1><p>These pages cover one concern each: the worker protocol, the state model, the detector contract, and the fixed widget rendering order.</p><p>Where to start:</p><ul><li><a href="./overview.html#two-actors">Two actors</a>: the parser worker and the view, and why the split exists.</li><li><a href="./detector-contract.html#detector-contract">Detector contract</a>: the interface every bottleneck detector implements. Read this before adding a finding.</li><li><a href="./impact-estimation.html#impact-estimation">Impact estimation</a>: how findings get a wall-clock/resource waste estimate attached.</li><li><a href="./../testing.html#testing-layout">Testing layout</a>: where tests live and what each layer covers.</li><li><a href="./../contributing.html">Contributing</a>: how architectural decisions get recorded as ADRs.</li></ul>',4)])])}const p=t(c,[["render",o]]);export{m as __pageData,p as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as t,o as r,c as a,a2 as i}from"./chunks/framework.CNDaxUod.js";const m=JSON.parse('{"title":"Architecture","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/index.md","filePath":"contributor-guide/architecture/index.md"}'),c={name:"contributor-guide/architecture/index.md"};function o(n,e,s,h,d,l){return r(),a("div",null,[...e[0]||(e[0]=[i("",4)])])}const p=t(c,[["render",o]]);export{m as __pageData,p as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as o,o as r,c as t,a2 as c}from"./chunks/framework.CNDaxUod.js";const m=JSON.parse('{"title":"Architecture overview","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/overview.md","filePath":"contributor-guide/architecture/overview.md"}'),a={name:"contributor-guide/architecture/overview.md"};function d(s,e,i,n,l,p){return r(),t("div",null,[...e[0]||(e[0]=[c('<h1 id="architecture-overview" tabindex="-1">Architecture overview <a class="header-anchor" href="#architecture-overview" aria-label="Permalink to "Architecture overview""></a></h1><h2 id="core-invariant" tabindex="-1">Core invariant <a class="header-anchor" href="#core-invariant" aria-label="Permalink to "Core invariant""></a></h2><p>Task data must never live on the main thread. A Spark job can emit millions of <code>SparkListenerTaskEnd</code> events (240 MB+ of metrics); parsing them on the UI thread freezes Chrome. The design below enforces that in both deploy modes: the static zero-backend app, and the optional local-server mode in <code>server/</code>, which also proxies Spark History Server fetches to sidestep CORS.</p><h2 id="two-actors" tabindex="-1">Two actors <a class="header-anchor" href="#two-actors" aria-label="Permalink to "Two actors""></a></h2><h3 id="main-thread" tabindex="-1">Main thread <a class="header-anchor" href="#main-thread" aria-label="Permalink to "Main thread""></a></h3><p>Owns the DOM (React) and a summary <code>AppModel</code> (jobs, stages, sql, executors, no raw tasks), held in a Zustand store (<code>src/store/store.ts</code>).</p><p><code>src/store/useIngest.ts</code> is the composition root. It owns the <code>IngestClient</code> lifecycle and wires <code>model-assembler.ts</code>'s <code>createModelCallbacks</code> (worker-message → <code>AppModel</code>, unchanged from before the view migration) into store updates: <code>onProgress</code> → <code>parse</code>; <code>onDone</code> → derives <code>evidenceAvailability</code> from the final normalized model plus <code>done.skippedLines</code>, runs <code>analyzer.ts</code>, sets <code>catalog</code>, and prefetches flagged-stage task data.</p><p><code>src/App.tsx</code> routes on store <code>status</code>: idle/error → <code>DropZone</code>, parsing → a live progress readout, ready → <code>Dashboard</code>. <code>src/view/Dashboard.tsx</code> and <code>src/view/detector-registry.tsx</code> replace <code>dashboard-renderer.js</code>'s widget board (see <a href="./widget-rendering.html">Widget rendering</a>).</p><h3 id="worker-web-worker-core-js" tabindex="-1">Worker (Web Worker, core JS) <a class="header-anchor" href="#worker-web-worker-core-js" aria-label="Permalink to "Worker (Web Worker, core JS)""></a></h3><p>Owns the file and <code>taskStore: Map<stageId, Float64Array></code>, keyed by 8-field stride <code>[duration, gcTime, memSpilled, diskSpilled, shuffleRead, shuffleWrite, launchTime, finishTime]</code>. Four modules:</p><ul><li><code>src/stage-quantiles.ts</code>, a pure leaf, exporting <code>finalizeStage</code>, <code>computeFieldQuantiles</code>, <code>computeDurationQuantiles</code>, <code>classifySpill</code>, <code>FIELDS</code>, and <code>TASK_FIELD_NAMES</code> for the task-field packed-array layout.</li><li><code>src/event-handlers.ts</code>, holding <code>processEvent</code> and the per-event handler functions, <code>accumulateTask</code>, <code>createState</code>, <code>dispatchLine</code>, <code>buildChunkDecoder</code>, <code>emitParseCompletion</code>, <code>collectStageExecutorMetrics</code>.</li><li><code>src/shs-fetch.ts</code>, the SHS zip-fetch/decompress path: <code>runParseFromUrl</code>, <code>naturalCompare</code>, <code>reassembleRollingEntries</code>, <code>sniffCodec</code>.</li><li><code>src/parser-worker.ts</code>, a thin entrypoint (<code>streamFile</code>, <code>runParse</code>, <code>runParseFiles</code>, the <code>isWorker</code>/<code>self.onmessage</code> bus) that barrel-re-exports the other three. It is the only piece needing the File/Blob streaming API.</li></ul><h2 id="streaming" tabindex="-1">Streaming <a class="header-anchor" href="#streaming" aria-label="Permalink to "Streaming""></a></h2><p>Worker reads the <code>File</code> in 4 MB chunks via <code>file.slice(...).arrayBuffer()</code>, <code>TextDecoder({ stream: true })</code>, splits on <code>\\n</code>, JSON-parses, dispatches per event type. Quantiles (P50/P95/max) and spill classification (<code>skew | volume | unclassified</code>) are computed at <code>SparkListenerStageCompleted</code> time, before posting <code>StageAggregate</code> to main.</p><p>When <code>spark.eventLog.logStageExecutorMetrics=true</code> (default <code>false</code>), <code>SparkListenerStageExecutorMetrics</code> events populate <code>stage.executorMetrics: Map<execId, {...}></code> with the 23 raw peak-memory/GC fields verbatim (camelCased), consumed by the <code>memoryUtilization</code> detector's per-executor memory bands (see <a href="./board-widgets.html">Memory Utilization</a>).</p><p>These events can arrive <em>after</em> <code>SparkListenerStageCompleted</code> for the same stage, so the per-stage <code>StageAggregate</code> message posted at completion time never carries them. Instead, <code>collectStageExecutorMetrics(state)</code> walks every stage once more just before <code>done</code>, and the worker re-posts a single <code>stageExecutorMetrics</code> message (<code>Map<stageId, Map<execId, metrics>></code>, empty when the log has no such data). Main-thread consumers get every stage's metrics without a per-stage race.</p><p>Compressed logs are inflated inline. <code>sniffCodec</code> reads the leading magic bytes: gzip (<code>1f 8b</code>), Zstandard (<code>28 b5 2f fd</code>, Spark's <code>spark.io.compression.codec=zstd</code>), Spark's custom <code>LZ4Block</code> framing, or Spark's Snappy framing (<code>org.xerial.snappy</code>'s <code>\\x82SNAPPY\\0</code> header, <code>spark.io.compression.codec=snappy</code>), falling back to the filename suffix. Both the dropped-file path and the SHS-fetch path stream block-by-block (fflate <code>Gunzip</code> / fzstd <code>Decompress</code> / the LZ4Block decoder / the Snappy block decoder) to keep one decompressed chunk live at a time.</p><p>The SHS-fetch path does buffer the downloaded zip whole for <code>unzipSync</code>. But one-shotting the decompression of a single entry (fzstd's <code>decompress()</code>, fflate's <code>gunzipSync()</code>) would allocate that entry's full declared content size as one ArrayBuffer, which fails outright for a multi-GB event log. So <code>decodeEntry(name, raw, onChunk)</code> picks the codec's streaming decoder per entry and invokes <code>onChunk</code> per decompressed piece. Decompressors are vendored under <code>src/vendor/</code> (<code>fflate.js</code>, <code>fzstd.js</code>) plus <code>src/lz4-block.ts</code> and <code>src/snappy-block.ts</code>.</p><p>Rolling <code>eventlog_v2_*</code> directories (Spark's multi-file event-log format, <code>events_<index>_<appId>(.codec)</code> files plus a zero-byte <code>appstatus_*</code> completion marker and, periodically, one <code>*.compact</code> merge file) are reassembled into parse order by <code>reassembleRollingEntries(names)</code>: drop the marker, drop every non-compact <code>events_*</code> file at or below the most recent <code>.compact</code> file's index, sort the rest numerically. Both the SHS-zip fetch path (<code>runParseFromUrl</code>) and the local folder-drop path (<code>runParseFiles</code>) call this one function.</p><p><code>runParseFiles</code> streams each file in the resulting order through the same chunked codec dispatch as <code>runParse</code>, with a fresh decompressor per file, since codecs never span a roll boundary. The NDJSON line-decoder is the exception: it stays alive across all files, because nothing guarantees a roll boundary lands on a line boundary.</p>',19)])])}const u=o(a,[["render",d]]);export{m as __pageData,u as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as o,o as r,c as t,a2 as c}from"./chunks/framework.CNDaxUod.js";const m=JSON.parse('{"title":"Architecture overview","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/overview.md","filePath":"contributor-guide/architecture/overview.md"}'),a={name:"contributor-guide/architecture/overview.md"};function d(s,e,i,n,l,p){return r(),t("div",null,[...e[0]||(e[0]=[c("",19)])])}const u=o(a,[["render",d]]);export{m as __pageData,u as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as o,o as t,c as a,a2 as s}from"./chunks/framework.CNDaxUod.js";const u=JSON.parse('{"title":"State and History Server intake","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/state-and-history.md","filePath":"contributor-guide/architecture/state-and-history.md"}'),r={name:"contributor-guide/architecture/state-and-history.md"};function d(n,e,c,i,l,h){return t(),a("div",null,[...e[0]||(e[0]=[s('<h1 id="state-and-history-server-intake" tabindex="-1">State and History Server intake <a class="header-anchor" href="#state-and-history-server-intake" aria-label="Permalink to "State and History Server intake""></a></h1><h2 id="state-model" tabindex="-1">State model <a class="header-anchor" href="#state-model" aria-label="Permalink to "State model""></a></h2><p><code>src/store/store.ts</code> is a single Zustand store (<code>createStore</code> from <code>zustand/vanilla</code>, wrapped by a <code>useStore</code> hook). It holds all shared state; no component keeps a <code>useState</code> of its own for anything shared: <code>appModel: AppModel</code>, <code>catalog: Finding[]</code>, <code>activeFileId</code>, <code>sessionCache</code> (in-memory snapshot cache for instant file-switching, see <code>session-snapshot.ts</code>), <code>taskDataCache</code>, <code>parse: {pct, lines, etaMs}</code>, <code>status: 'idle'|'parsing'|'ready'|'error'</code>, <code>errorMessage</code>, <code>theme</code>, <code>skippedLines</code> (malformed-JSON-line count from the parser's <code>done</code> payload).</p><p><code>src/store/useIngest.ts</code> is the only writer during a parse. It builds <code>createModelCallbacks</code>' <code>onProgress</code>/<code>onDone</code>/<code>onError</code> handlers to call the store's setters directly (<code>setParse</code>, <code>setCatalog</code>, <code>setStatus</code>, <code>setSkippedLines</code>, ...). Components never talk to the worker: they use <code>useIngest()</code>'s returned actions (<code>startLoad</code>/<code>startLoadFolder</code>/<code>startLoadFromUrl</code>/<code>pickRecent</code>/<code>getTaskData</code>/ <code>resetToDropZone</code>) and the store's read state.</p><p><code>resetModel()</code> empties <code>appModel</code>/<code>catalog</code>/<code>taskDataCache</code>/<code>skippedLines</code> on every new parse or reset-to-drop-zone, and bumps <code>modelResetCount</code>. That counter has no setter of its own; only <code>PlanGraphRoute.tsx</code>'s <code>store.subscribe</code> reads it, to evict the plan-graph model memo cache (see <a href="./drill-down.html#plan-graph-view">Plan graph view</a>).</p><h3 id="finding-filter-state" tabindex="-1">Finding filter state <a class="header-anchor" href="#finding-filter-state" aria-label="Permalink to "Finding filter state""></a></h3><p>The board-wide finding filter (impact band, raw <code>finding.type</code>, stage) lives outside the Zustand store, in <code>FindingFilterContext</code> (<code>src/view/FindingFilterContext.tsx</code>): a <code>createContext</code>+<code>useState</code><code>FilterSelection</code> (<code>src/view/finding-filter.ts</code>, three <code>Set</code>s) that every widget filters <code>catalog</code> through via <code>filterFindings</code>. It is seeded from the URL's <code>impact</code>/<code>type</code>/<code>stage</code> query params on mount and nowhere else, so a reload with no params gives the unfiltered board. Every change writes those params back with <code>history.replaceState</code>, never <code>push</code>, so filtering doesn't grow Back history. A <code>popstate</code> listener re-seeds the selection from the URL, so Back and forward re-apply filters.</p><p>Switching files resets the selection to empty, keyed on the stable file id rather than <code>catalog</code>, so a same-file catalog refresh keeps the active filter. A page reload re-reads the address bar, so deep-linked filtered URLs still restore. See <a href="https://github.com/shuffle-works/sparkforensics/blob/main/docs/adr/0008-finding-filters-shareable-state.md" target="_blank" rel="noreferrer">ADR 0008</a> for the rationale.</p><h3 id="recent-files-vs-session-cache" tabindex="-1">Recent files vs. session cache <a class="header-anchor" href="#recent-files-vs-session-cache" aria-label="Permalink to "Recent files vs. session cache""></a></h3><p>Two mechanisms cover reopening a file, at different lifetimes.</p><p><code>sessionCache</code> (in the Zustand store, see <a href="#state-model">State model</a>) is in-memory and per-session: it makes switching between files already loaded in the current tab instant, and it is gone on reload.</p><p>Recent files (<code>src/recent-files.ts</code>, consumed by <code>src/view/useRecentFiles.ts</code>) is IndexedDB-backed and cross-session. It persists each file's <code>FileSystemFileHandle</code> plus light metadata (name, size, <code>lastModified</code>, app name, issue count, <code>lastOpenedAt</code>), capped at 10 entries with the oldest evicted past the cap, so a file can be reopened after a full browser restart, pending the browser re-granting permission on the handle. Picking a recent entry re-parses from the handle; no parsed model is ever persisted.</p><h2 id="run-comparison" tabindex="-1">Run comparison <a class="header-anchor" href="#run-comparison" aria-label="Permalink to "Run comparison""></a></h2><p><code>src/run-comparison.ts</code> is the whole A/B engine (ELV-001). The entry point <code>compareRuns(baseline, candidate)</code> takes two <code>{ label, snapshot }</code> run records (each <code>snapshot</code> a normalized model: <code>app</code>, <code>stages</code>, <code>sql</code>, <code>catalog</code>, <code>executors</code>) and returns one plain object the view renders. It runs on already-parsed snapshots, with no worker involved.</p><ul><li><code>stageIdentity(stage, snapshot)</code> is a run-independent key: <code>normalizeStageName</code> (lowercased, digit-runs and long hex ids collapsed to <code>#</code>) joined with the stage's SQL-execution plan identity, a bottom-up structural fingerprint of the resolved <code>planTree</code> (<code>planTreeIdentity</code>) that folds each node's normalized name and <code>normalizeDetail</code>-normalized detail with its children's identities (children sorted, so sibling reordering across runs still matches). <code>normalizeDetail</code> is the same node-detail normalizer <code>cachingOpportunity</code>'s composite join/union detector uses (<code>src/detectors.ts</code>): it strips expr ids, <code>plan_id=</code>, codegen-stage numbers, and AQE's BuildLeft/BuildRight choice, and canonicalizes commutative equality operands, so the identity survives run-to-run noise without collapsing structurally or semantically different plans together. <code>matchStages(baseSnap, candSnap)</code> indexes each run by that identity and pairs only identities that map to exactly one stage on both sides. Ambiguous (colliding) identities are recorded per-run but never paired, and <code>coverage</code> reports the matched fraction.</li><li><code>metricDeltas</code> computes whole-run aggregate deltas (wall-clock, spill, task skew p95, failed-task rate, GC, I/O bytes, executor count, ...) as plain sums over all stages, deliberately not gated on stage matching, since matching is unreliable on real logs. Each metric carries a <code>direction</code> (improvement/regression/unchanged) and an <code>unavailableReason</code> when a side lacks the field.</li><li><code>findingsDelta</code> tallies each run's <code>catalog</code> by <code>(rule × impact band)</code> and reports <code>introduced</code> vs <code>resolved</code> categories: a count diff, also matching-free.</li><li>Only the per-stage skew deltas (<code>stageSkewDeltas</code>) and the pinned-stage panel consume the <code>matchStages</code> pairs, so low match coverage degrades those two surfaces without invalidating the aggregate deltas.</li></ul><p><code>compareRuns</code> also flags <code>confidence: 'low'</code> when the two app names differ (a weak signal, not a hard gate). The view lives in <code>src/view/RunComparison.tsx</code> (the comparison page), <code>CompareLanding.tsx</code> (the two-slot Run A / Run B intake off the landing), and <code>PinnedStageDeltas.tsx</code> (the manual per-stage pinning panel fed by <code>baseStages</code>/<code>candStages</code>).</p><h2 id="history-server-intake-and-recovery" tabindex="-1">History Server intake and recovery <a class="header-anchor" href="#history-server-intake-and-recovery" aria-label="Permalink to "History Server intake and recovery""></a></h2><p><code>DropZone</code> keeps the History Server disclosure, Base URL, Application ID, optional Attempt ID, validation/touched state, and recoverable SHS error in mounted React state rather than Zustand. The local browser-first path is still the default: <strong>Choose file</strong> loads a single event log, while <strong>Choose rolling-log folder</strong> accepts only an <code>eventlog_v2_*</code> directory and directs a rejected folder back to the file picker.</p><p>The collapsed <strong>Fetch from Spark History Server</strong> disclosure requires local-server mode, a reachable History Server, and a supported base application ID: <code>application_<timestamp>_<id></code>, <code>local-<timestamp></code>, or <code>app-<identifier></code>. <code>server/lib/shs-request.js</code> trims and validates the three request fields, accepts only absolute credential-free <code>http:</code>/<code>https:</code> base URLs without a query or fragment, preserves a reverse-proxy path prefix, and canonicalizes the base URL to one trailing slash. The optional attempt is a separate path-safe identifier; neither identifier can contain a path separator. The shared helper builds the encoded <code>/shs-proxy</code> request and the encoded <code>api/v1/applications/<app>[/<attempt>]/logs</code> upstream path from that normalized object only.</p><p>The optional Node server is loopback-only: a narrow CORS proxy, not a general or hosted proxy. It validates the same request contract, sends no credentials, follows no upstream redirects, and returns only stable safe error codes. It never forwards upstream response text, status details, locations, or credentials to the browser.</p><p>Routing preserves the recovery boundary: local file and folder failures use the existing page-level error route. A typed SHS failure instead resets the model to idle and returns to the still-mounted, expanded History Server disclosure, which retains its values and shows safe recovery guidance along with local file intake. During an SHS parse, the mounted intake shows progress in place; successful completion follows the normal dashboard route.</p>',21)])])}const g=o(r,[["render",d]]);export{u as __pageData,g as default};
|
package/public/docs/assets/contributor-guide_architecture_state-and-history.md.DaqttKgQ.lean.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as o,o as t,c as a,a2 as s}from"./chunks/framework.CNDaxUod.js";const u=JSON.parse('{"title":"State and History Server intake","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/state-and-history.md","filePath":"contributor-guide/architecture/state-and-history.md"}'),r={name:"contributor-guide/architecture/state-and-history.md"};function d(n,e,c,i,l,h){return t(),a("div",null,[...e[0]||(e[0]=[s("",21)])])}const g=o(r,[["render",d]]);export{u as __pageData,g as default};
|