From 1191b3d994756fac070f3603a74a233a9a7cf6cd Mon Sep 17 00:00:00 2001 From: Pim van Pelt Date: Sun, 12 Apr 2026 23:50:22 +0200 Subject: [PATCH] Frontend aggregate state: SPA-side derive + checker fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web UI showed the wrong up/down state for frontends whose pool composition had been touched by a mix of runtime disable/enable and weight changes: a frontend with every backend at effective_weight=0 would still display "up", while a sibling frontend with a serving fallback backend would display "down". Two independent bugs, each fixed on its own layer. On the fast path (healthCheckEqual returns true), Reload did `w.entry = b`, blindly replacing the runtime worker entry with the fresh YAML record. YAML's default for Enabled is true, so any backend the operator had runtime-disabled would have its Enabled flag silently reset while the worker's backend.State stayed at StateDisabled. Subsequent EnableBackend calls then early-returned on `if w.entry.Enabled` and never transitioned the state machine — the CLI reported "enabled, state is 'disabled'" and the backend was permanently stuck. Fix: preserve w.entry.Enabled across the fast-path replacement. runtimeEnabled := w.entry.Enabled w.entry = b w.entry.Enabled = runtimeEnabled Runtime operator state now outlives config reloads. On the worker- restart path (different health check) the new worker is structurally fresh and the YAML's Enabled is still authoritative. Both methods used `w.entry.Enabled` as their idempotency check, which meant a stuck `Enabled=true, State=disabled` combo couldn't be repaired even after the Reload fix (existing bad state had to survive the upgrade). Switched both methods to key on `w.backend.State`: - DisableBackend: if state == StateDisabled, sync the flag but don't emit a redundant transition; otherwise do the full state transition + flag flip + worker cancel. - EnableBackend: if state != StateDisabled, sync the flag but don't emit a redundant transition; otherwise do the full transition + flag flip + probe-goroutine restart. Either method will now unstick any inconsistency between the flag and the state machine — future drift from a panic, a new code path we haven't thought of, or existing already-stuck backends from before this commit are all repaired on the next enable/disable call. Changing a backend's weight can flip a frontend between up and down (e.g. zeroing the last non-zero-weighted backend in the active pool), but SetFrontendPoolBackendWeight never called updateFrontendState, so the checker's cached frontend state would drift from reality until the next genuine backend transition happened to trigger a recompute. The symptom was "show frontends nginx-ip4-http" reporting up even with every effective_weight=0. Fix: call c.updateFrontendState(frontendName, fe) after the weight mutation, under the same lock. The recompute emits a FrontendEvent transition if the aggregate flipped, so any WatchEvents consumer picks up the change live. stores/state.ts recomputeEffectiveWeights is renamed and extended to recomputeDerivedState, which now also writes fe.state using the same rule as health.ComputeFrontendState: unknown if no backends or all unknown, up if any effective weight > 0, down otherwise. Called from every mutation path (replaceAll, replaceSnapshot, applyBackendTransition, applyConfiguredWeight) so the SPA is authoritative for *display* state and doesn't inherit any staleness the server's cached frontendStates map might have. applyFrontendTransition is now a no-op for the state field — the server's `to` value is no longer trusted because recomputeDerivedState walks the local backends array on every update and produces a fresh, correct answer. The reducer is kept as a named function so sse.ts's dispatch table still has a landing spot for "frontend" events (they still feed the DebugPanel via pushEvent); the empty body is deliberate, not a bug — a comment at the top spells it out. --- .../web/dist/assets/index-BBNMNdtq.js | 1 - .../web/dist/assets/index-C-XMkBf5.js | 1 + cmd/frontend/web/dist/index.html | 2 +- cmd/frontend/web/src/stores/state.ts | 101 +++++++++++++----- internal/checker/checker.go | 54 +++++++++- 5 files changed, 125 insertions(+), 34 deletions(-) delete mode 100644 cmd/frontend/web/dist/assets/index-BBNMNdtq.js create mode 100644 cmd/frontend/web/dist/assets/index-C-XMkBf5.js diff --git a/cmd/frontend/web/dist/assets/index-BBNMNdtq.js b/cmd/frontend/web/dist/assets/index-BBNMNdtq.js deleted file mode 100644 index 039d0ba..0000000 --- a/cmd/frontend/web/dist/assets/index-BBNMNdtq.js +++ /dev/null @@ -1 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))s(r);new MutationObserver(r=>{for(const l of r)if(l.type==="childList")for(const i of l.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&s(i)}).observe(document,{childList:!0,subtree:!0});function n(r){const l={};return r.integrity&&(l.integrity=r.integrity),r.referrerPolicy&&(l.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?l.credentials="include":r.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function s(r){if(r.ep)return;r.ep=!0;const l=n(r);fetch(r.href,l)}})();const at=!1,ct=(e,t)=>e===t,F=Symbol("solid-proxy"),xe=Symbol("solid-track"),ue={equals:ct};let Ve=Ge;const U=1,fe=2,We={owned:null,cleanups:null,context:null,owner:null};var v=null;let ke=null,ut=null,p=null,E=null,D=null,we=0;function ne(e,t){const n=p,s=v,r=e.length===0,l=t===void 0?s:t,i=r?We:{owned:null,cleanups:null,context:l?l.context:null,owner:l},o=r?e:()=>e(()=>M(()=>se(i)));v=i,p=null;try{return G(o,!0)}finally{p=n,v=s}}function x(e,t){t=t?Object.assign({},ue,t):ue;const n={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},s=r=>(typeof r=="function"&&(r=r(n.value)),He(n,r));return[Fe.bind(n),s]}function C(e,t,n){const s=Pe(e,t,!1,U);ie(s)}function H(e,t,n){Ve=pt;const s=Pe(e,t,!1,U);(!n||!n.render)&&(s.user=!0),D?D.push(s):ie(s)}function W(e,t,n){n=n?Object.assign({},ue,n):ue;const s=Pe(e,t,!0,0);return s.observers=null,s.observerSlots=null,s.comparator=n.equals||void 0,ie(s),Fe.bind(s)}function ft(e){return G(e,!1)}function M(e){if(p===null)return e();const t=p;p=null;try{return e()}finally{p=t}}function dt(e,t,n){const s=Array.isArray(e);let r,l=n&&n.defer;return i=>{let o;if(s){o=Array(e.length);for(let u=0;ut(o,r,i));return r=o,a}}function gt(e){H(()=>M(e))}function Y(e){return v===null||(v.cleanups===null?v.cleanups=[e]:v.cleanups.push(e)),e}function Ae(){return p}function ht(){return v}function bt(e,t){const n=v,s=p;v=e,p=null;try{return G(t,!0)}catch(r){Oe(r)}finally{v=n,p=s}}function Fe(){if(this.sources&&this.state)if(this.state===U)ie(this);else{const e=E;E=null,G(()=>ge(this),!1),E=e}if(p){const e=this.observers?this.observers.length:0;p.sources?(p.sources.push(this),p.sourceSlots.push(e)):(p.sources=[this],p.sourceSlots=[e]),this.observers?(this.observers.push(p),this.observerSlots.push(p.sources.length-1)):(this.observers=[p],this.observerSlots=[p.sources.length-1])}return this.value}function He(e,t,n){let s=e.value;return(!e.comparator||!e.comparator(s,t))&&(e.value=t,e.observers&&e.observers.length&&G(()=>{for(let r=0;r1e6)throw E=[],new Error},!1)),t}function ie(e){if(!e.fn)return;se(e);const t=we;mt(e,e.value,t)}function mt(e,t,n){let s;const r=v,l=p;p=v=e;try{s=e.fn(t)}catch(i){return e.pure&&(e.state=U,e.owned&&e.owned.forEach(se),e.owned=null),e.updatedAt=n+1,Oe(i)}finally{p=l,v=r}(!e.updatedAt||e.updatedAt<=n)&&(e.updatedAt!=null&&"observers"in e?He(e,s):e.value=s,e.updatedAt=n)}function Pe(e,t,n,s=U,r){const l={fn:e,state:s,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:v,context:v?v.context:null,pure:n};return v===null||v!==We&&(v.owned?v.owned.push(l):v.owned=[l]),l}function de(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&M(e.suspense.inFallback))return e.suspense.effects.push(e);const t=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;n--)if(e=t[n],e.state===U)ie(e);else if(e.state===fe){const s=E;E=null,G(()=>ge(e,t[0]),!1),E=s}}function G(e,t){if(E)return e();let n=!1;t||(E=[]),D?n=!0:D=[],we++;try{const s=e();return $t(n),s}catch(s){n||(D=null),E=null,Oe(s)}}function $t(e){if(E&&(Ge(E),E=null),e)return;const t=D;D=null,t.length&&G(()=>Ve(t),!1)}function Ge(e){for(let t=0;t=0;t--)se(e.tOwned[t]);delete e.tOwned}if(e.owned){for(t=e.owned.length-1;t>=0;t--)se(e.owned[t]);e.owned=null}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null}e.state=0}function wt(e){return e instanceof Error?e:new Error(typeof e=="string"?e:"Unknown error",{cause:e})}function Oe(e,t=v){throw wt(e)}const vt=Symbol("fallback");function De(e){for(let t=0;t1?[]:null;return Y(()=>De(l)),()=>{let a=e()||[],u=a.length,f,c;return a[xe],M(()=>{let b,y,k,P,B,m,w,_,A;if(u===0)i!==0&&(De(l),l=[],s=[],r=[],i=0,o&&(o=[])),n.fallback&&(s=[vt],r[0]=ne(S=>(l[0]=S,n.fallback())),i=1);else if(i===0){for(r=new Array(u),c=0;c=m&&_>=m&&s[w]===a[_];w--,_--)k[_]=r[w],P[_]=l[w],o&&(B[_]=o[w]);for(b=new Map,y=new Array(_+1),c=_;c>=m;c--)A=a[c],f=b.get(A),y[c]=f===void 0?-1:f,b.set(A,c);for(f=m;f<=w;f++)A=s[f],c=b.get(A),c!==void 0&&c!==-1?(k[c]=r[f],P[c]=l[f],o&&(B[c]=o[f]),c=y[c],b.set(A,c)):l[f]();for(c=m;ce(t||{}))}const _t=e=>`Stale read from <${e}>.`;function J(e){const t="fallback"in e&&{fallback:()=>e.fallback};return W(yt(()=>e.each,e.children,t||void 0))}function O(e){const t=e.keyed,n=W(()=>e.when,void 0,void 0),s=t?n:W(n,void 0,{equals:(r,l)=>!r==!l});return W(()=>{const r=s();if(r){const l=e.children;return typeof l=="function"&&l.length>0?M(()=>l(t?r:()=>{if(!M(s))throw _t("Show");return n()})):l}return e.fallback},void 0,void 0)}const L=e=>W(()=>e());function St(e,t,n){let s=n.length,r=t.length,l=s,i=0,o=0,a=t[r-1].nextSibling,u=null;for(;if-o){const y=t[i];for(;o{r=l,t===document?e():d(t,e(),t.firstChild?null:void 0,n)},s.owner),()=>{r(),t.textContent=""}}function h(e,t,n,s){let r;const l=()=>{const o=document.createElement("template");return o.innerHTML=e,o.content.firstChild},i=()=>(r||(r=l())).cloneNode(!0);return i.cloneNode=i,i}function ve(e,t=window.document){const n=t[Me]||(t[Me]=new Set);for(let s=0,r=e.length;se(t,n))}function d(e,t,n,s){if(n!==void 0&&!s&&(s=[]),typeof t!="function")return he(e,t,s,n);C(r=>he(e,t(),r,n),s)}function Ct(e){let t=e.target;const n=`$$${e.type}`,s=e.target,r=e.currentTarget,l=a=>Object.defineProperty(e,"target",{configurable:!0,value:a}),i=()=>{const a=t[n];if(a&&!t.disabled){const u=t[`${n}Data`];if(u!==void 0?a.call(t,u,e):a.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!="string"&&!t.host._$host&&t.contains(e.target)&&l(t.host),!0},o=()=>{for(;i()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:!0,get(){return t||document}}),e.composedPath){const a=e.composedPath();l(a[0]);for(let u=0;u{let o=t();for(;typeof o=="function";)o=o();n=he(e,o,n,s)}),()=>n;if(Array.isArray(t)){const o=[],a=n&&Array.isArray(n);if(Ce(o,t,n,r))return C(()=>n=he(e,o,n,s,!0)),()=>n;if(o.length===0){if(n=K(e,n,s),i)return n}else a?n.length===0?Be(e,o,s):St(e,n,o):(n&&K(e),Be(e,o));n=o}else if(t.nodeType){if(Array.isArray(n)){if(i)return n=K(e,n,s,t);K(e,n,null,t)}else n==null||n===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);n=t}}return n}function Ce(e,t,n,s){let r=!1;for(let l=0,i=t.length;l=0;i--){const o=t[i];if(r!==o){const a=o.parentNode===e;!l&&!i?a?e.replaceChild(r,o):e.insertBefore(r,n):a&&o.remove()}else l=!0}}else e.insertBefore(r,n);return[r]}const Et="http://www.w3.org/2000/svg";function Pt(e,t=!1,n=void 0){return t?document.createElementNS(Et,e):document.createElement(e,{is:n})}function Ot(e){const{useShadow:t}=e,n=document.createTextNode(""),s=()=>e.mount||document.body,r=ht();let l;return H(()=>{l||(l=bt(r,()=>W(()=>e.children)));const i=s();if(i instanceof HTMLHeadElement){const[o,a]=x(!1),u=()=>a(!0);ne(f=>d(i,()=>o()?f():l(),null)),Y(u)}else{const o=Pt(e.isSVG?"g":"div",e.isSVG),a=t&&o.attachShadow?o.attachShadow({mode:"open"}):o;Object.defineProperty(o,"_$host",{get(){return n.parentNode},configurable:!0}),d(a,l),i.appendChild(o),e.ref&&e.ref(o),Y(()=>i.removeChild(o))}},void 0,{render:!0}),n}async function qe(e){const t=await fetch(e,{credentials:"same-origin"});if(!t.ok)throw new Error(`${e}: ${t.status} ${t.statusText}`);return await t.json()}function Ye(){return qe("/view/api/state")}function Tt(){return qe("/view/api/version")}const be=Symbol("store-raw"),q=Symbol("store-node"),j=Symbol("store-has"),Je=Symbol("store-self");function Xe(e){let t=e[F];if(!t&&(Object.defineProperty(e,F,{value:t=new Proxy(e,It)}),!Array.isArray(e))){const n=Object.keys(e),s=Object.getOwnPropertyDescriptors(e);for(let r=0,l=n.length;re[F][t]),n}function ze(e){Ae()&&le(me(e,q),Je)()}function Lt(e){return ze(e),Reflect.ownKeys(e)}const It={get(e,t,n){if(t===be)return e;if(t===F)return n;if(t===xe)return ze(e),n;const s=me(e,q),r=s[t];let l=r?r():e[t];if(t===q||t===j||t==="__proto__")return l;if(!r){const i=Object.getOwnPropertyDescriptor(e,t);Ae()&&(typeof l!="function"||e.hasOwnProperty(t))&&!(i&&i.get)&&(l=le(s,t,l)())}return X(l)?Xe(l):l},has(e,t){return t===be||t===F||t===xe||t===q||t===j||t==="__proto__"?!0:(Ae()&&le(me(e,j),t)(),t in e)},set(){return!0},deleteProperty(){return!0},ownKeys:Lt,getOwnPropertyDescriptor:Nt};function Q(e,t,n,s=!1){if(!s&&e[t]===n)return;const r=e[t],l=e.length;n===void 0?(delete e[t],e[j]&&e[j][t]&&r!==void 0&&e[j][t].$()):(e[t]=n,e[j]&&e[j][t]&&r===void 0&&e[j][t].$());let i=me(e,q),o;if((o=le(i,t,r))&&o.$(()=>n),Array.isArray(e)&&e.length!==l){for(let a=e.length;a1){s=t.shift();const i=typeof s,o=Array.isArray(e);if(Array.isArray(s)){for(let a=0;a1){te(e[s],t,[s].concat(n));return}r=e[s],n=[s].concat(n)}let l=t[0];typeof l=="function"&&(l=l(r,n),l===r)||s===void 0&&l==null||(l=z(l),s===void 0||X(r)&&X(l)&&!Array.isArray(l)?Qe(r,l):Q(e,s,l))}function Dt(...[e,t]){const n=z(e||{}),s=Array.isArray(n),r=Xe(n);function l(...i){ft(()=>{s&&i.length===1?jt(n,i[0]):te(n,i)})}return[r,l]}const $e=new WeakMap,Ze={get(e,t){if(t===be)return e;const n=e[t];let s;return X(n)?$e.get(n)||($e.set(n,s=new Proxy(n,Ze)),s):n},set(e,t,n){return Q(e,t,z(n)),!0},deleteProperty(e,t){return Q(e,t,void 0,!0),!0}};function oe(e){return t=>{if(X(t)){let n;(n=$e.get(t))||$e.set(t,n=new Proxy(t,Ze)),e(n)}return t}}const[Mt,Bt]=x(0);setInterval(()=>Bt(e=>e+1),5e3);function et(e){const t={};for(const n of e.backends)t[n.name]=n.state;for(const n of e.frontends){let s=0;for(let r=0;r0){l=!0;break}if(l){s=r;break}}for(let r=0;r{const s=n.byName[e];if(!s)return;const r=s.backends.find(l=>l.name===t.backend);r&&(r.state=t.transition.to,r.enabled=t.transition.to!=="disabled",r.last_transition=t.transition,r.transitions||(r.transitions=[]),r.transitions.push(t.transition),r.transitions.length>20&&(r.transitions=r.transitions.slice(r.transitions.length-20)),et(s))}))}function Ut(e,t){Z(oe(n=>{const s=n.byName[e];if(!s)return;const r=s.frontends.find(l=>l.name===t.frontend);r&&(r.state=t.transition.to)}))}function Vt(e,t){Z(oe(n=>{const s=n.byName[e];s&&(s.vpp_state=t)}))}function Wt(e,t){Z(oe(n=>{const s=n.byName[e];s&&(s.maglevd.connected=t.connected,s.maglevd.last_error=t.last_error)}))}function Ft(e,t,n,s,r){Z(oe(l=>{const i=l.byName[e];if(!i)return;const o=i.frontends.find(f=>f.name===t);if(!o)return;const a=o.pools.find(f=>f.name===n);if(!a)return;const u=a.backends.find(f=>f.name===s);u&&(u.weight=r,et(i))}))}function Ht(e,t){return e.includes(":")?`[${e}]:${t}`:`${e}:${t}`}function Gt(e){if(Mt(),!e||!e.at_unix_ns||e.at_unix_ns<=0)return"";const t=Date.now()-e.at_unix_ns/1e6,n=Math.floor(t/1e3);if(n<=1)return"now";const s=n%60,r=Math.floor(n/60);if(r<1)return`${n}s ago`;const l=r%60,i=Math.floor(r/60);if(i<1)return`${l}m${s}s ago`;const o=i%24,a=Math.floor(i/24);return a<1?`${i}h${l}m ago`:`${a}d${o}h ago`}const Re=500,[Ee,Kt]=x([]);function qt(e){Kt(t=>{const n=[...t,e];return n.length>Re?n.slice(n.length-Re):n})}function Yt(){const e=new EventSource("/view/api/events");return e.onmessage=t=>{try{const n=JSON.parse(t.data);Jt(n)}catch(n){console.error("sse parse error",n,t.data)}},e.addEventListener("resync",async()=>{try{const t=await Ye();tt(t)}catch(t){console.error("resync refetch failed",t)}}),e.onerror=t=>{console.debug("sse error, browser will reconnect",t)},e}function Jt(e){switch(qt(e),e.type){case"backend":Rt(e.maglevd,e.payload);break;case"frontend":Ut(e.maglevd,e.payload);break;case"maglevd-status":Wt(e.maglevd,e.payload);break;case"vpp-status":Vt(e.maglevd,e.payload.state);break}}const[_e,nt]=x(void 0);var Xt=h("