/* Bee Smart Operations Portal - PM-197 Insights: five graphical data views over GET /api/trends (24h/7d/30d). Shared rules on every view: RangeSwitcher, DERIVED badge (formula on hover) on every derived metric, honest gaps (null bucket = void, '-' for absent sources: RSSI PM-160 / FW PM-175 / AQI PM-180, never interpolate). A 404 from /api/trends renders the explicit "trend service not yet deployed" state (phase-A honesty: /next fetches the PROD API). */ // Trend fetch hook: phase 'loading' | 'ready' | 'error' | 'unavailable'(404). // Refetches on range switch; keeps the last data visible while reloading. function useTrends(initial){ const [range,setRange]=React.useState(initial||'24h'); const [st,setSt]=React.useState({phase:'loading',data:null,err:null}); // Request-id guard: on a fast range flip, an older slower response must never // overwrite a newer one (stale data under a fresh label violates honesty). const reqId=React.useRef(0); const load=React.useCallback(r=>{ const my=++reqId.current; setSt(s=>({phase:'loading',data:s.data,err:null})); window.loadTrends(r) .then(data=>{ if(my===reqId.current) setSt({phase:'ready',data,err:null}); }) .catch(e=>{ if(my===reqId.current) setSt({phase:e.code===404?'unavailable':'error',data:null,err:String(e&&e.message||e)}); }); },[]); React.useEffect(()=>{ load(range); },[range,load]); return { range, setRange, phase:st.phase, data:st.data, err:st.err, reload:()=>load(range) }; } // Formula lookup for DERIVED badges (served in every payload's derivedMeta). function metaOf(data,key){ const m=data&&data.derivedMeta&&data.derivedMeta.find(x=>x.key===key); return m?m.formula:key; } // Availability for the CURRENT range window. function availFor(dev,range){ return range==='24h'?dev.availabilityPct.h24:range==='7d'?dev.availabilityPct.d7:dev.availabilityPct.d30; } // Prototype-chrome note (mirrors Screens.jsx ReviewNote, which is file-local there). function InsightNote({ title, items }){ if(PROD_PORTAL) return null; return
Review note - {title}. {items.join(' · ')}
; } // Shared loading / 404 / error gate. children render only with data. function TrendGate({ t, children }){ if(t.phase==='loading'&&!t.data) return
loading trends...
; if(t.phase==='unavailable') return
Trend service not yet deployed
This surface fetches the production API, and /api/trends has not shipped to production yet - it arrives with the first server-side portal deploy (PM-197 phase B). Live fleet status everywhere else is unaffected.
; if(t.phase==='error') return
Couldn't load trends
{t.err}
; return children; } // PageTitle right slot: RangeSwitcher + a small in-flight pill while a refetch // runs over existing data (TrendGate keeps the old payload visible; without this // a slow range switch would look like nothing happened). View labels read the // PAYLOAD's d.range so label and data always agree during the refetch window. function RangeRight({ t }){ return {t.phase==='loading'&&t.data?:null} ; } // ================= FLEET RHYTHMS (Shell page, C-layout) ================= function FleetRhythms(){ const t=useTrends('24h'); const d=t.data; return
}/> {d&&<>
}/> v===0?null:v)}/>
dashed cell = true gap (no readings in the bucket)
}/> {d.devices.map(dev=>{ const a=availFor(dev,d.range); return
{dev.name}
{a!=null&&=99?O.leaf:a>=90?O.warn:O.crit,borderRadius:5}}/>}
{a==null?'-':a+'%'} {dev.gaps.length} gaps
;})}
}/>
{d.devices.map(dev=>{ const f=dev.freshness; const tone=f.lastSeenMin<=f.expectedCadenceMin?'ok':f.lastSeenMin<=90?'warn':'crit'; return ;})}
}
; } // ================= MICROCLIMATE (Shell page, C-layout) ================= // Stacked aligned ribbons: temp / RH / baro / NOISE (dB - SHOWN per the s376 // owner decision, resolving the s353 open question), sensor lines + gateway env // lines; DERIVED dew-point ribbon (Magnus, server-derived); Sensor-1-vs-Sensor-2 // divergence; pressure-trend card. // Client-side divergence bands per channel (display heuristic, labeled DERIVED). const DIVERGE_BAND={ TEMP:2, HUMID:5, AIR_PRESS:0.05, NOISE:6 }; const DIVERGE_FORMULA='Sensor 1 - Sensor 2 per bucket (both reporting); flagged beyond the per-channel band'; function Microclimate(){ const t=useTrends('7d'); const d=t.data; // owner direction: Microclimate defaults to 7-day (Device Vitals stays 24h) const sensors=d?d.devices.filter(x=>x.kind==='sensor'):[]; const gws=d?d.devices.filter(x=>x.kind==='gateway'):[]; const colors=[O.azure,O.teal,O.gold,O.leaf4]; const lastOf=v=>{ for(let i=v.length-1;i>=0;i--) if(v[i]!=null) return v[i]; return null; }; const envRibbon=(title,key,gwKey,unit,icon,derivedKey)=>{ const series=[ ...sensors.map((s,i)=>({label:s.name,color:colors[i%colors.length],values:(derivedKey?s.derived.dewPointF:s.channels[key])||[]})), ...(gwKey?gws.map(g=>({label:g.name+' (gw)',color:O.fg3,values:(derivedKey?g.derived.dewPointF:g.channels[gwKey])||[]})):[]), ].filter(s=>s.values.length); return :undefined}/> ; }; // per-channel divergence (needs exactly the first two sensors) const s0=sensors[0], s1=sensors[1]; const delta=key=>{ if(!s0||!s1) return null; const a=s0.channels[key]||[], b=s1.channels[key]||[]; return a.map((v,i)=>v!=null&&b[i]!=null?Math.round((v-b[i])*100)/100:null); }; const presSrc = sensors.find(s=>s.channels.AIR_PRESS&&s.channels.AIR_PRESS.some(v=>v!=null))||gws.find(g=>g.channels.GW_AIR_PRESS&&g.channels.GW_AIR_PRESS.some(v=>v!=null))||null; const presSeries = presSrc ? (presSrc.kind==='gateway'?presSrc.channels.GW_AIR_PRESS:presSrc.channels.AIR_PRESS) : []; const presTrend = presSrc ? presSrc.derived.pressureTrend : null; return
}/> {d&&<> {envRibbon('Temperature','TEMP','GW_TEMP','F','thermometer')} {envRibbon('Humidity','HUMID','GW_HUMID','%','droplets')} {envRibbon('Barometric pressure','AIR_PRESS','GW_AIR_PRESS','inHg','gauge')} {envRibbon('Noise','NOISE',null,'dB','volume-2')} {envRibbon('Dew point','TEMP','GW_TEMP','F','cloud-drizzle',true)}
}/> {s0&&s1?<> {['TEMP','HUMID','AIR_PRESS','NOISE'].map((key,i)=>{ const dv=delta(key); const cur=dv?lastOf(dv):null; const band=DIVERGE_BAND[key]; const flagged=cur!=null&&Math.abs(cur)>band; return
{key.replace('AIR_PRESS','BARO')}
{cur==null?'-':(cur>0?'+':'')+cur}{flagged?' !':''}
;})} :
Divergence needs two sensors reporting in range.
}
}/>
{presTrend==null?'-':presTrend.toUpperCase()} {presTrend&&}
{presSeries.length>0&&}
}
; } // ================= HEALTH SCORECARD (Shell page, C-layout) ================= // Devices x four golden signals (traffic / freshness / errors / saturation) + // availability SLA columns 24h/7d/30d + fleet summary row + honest alert // burn-down (current posture only; history '-' until PM-161). Defaults to 30d so // all three SLA windows fill. Row tiles drill into Device Vitals. function HealthScorecard({ onVitals, siteId }){ const t=useTrends('30d'); const d=t.data; const site=siteId?(SITES.find(s=>s.id===siteId)||null):null; // per-grower scope (null = whole fleet) const sla=v=>v==null?'-':v+'%'; const slaTone=v=>v==null?'idle':v>=99?'ok':v>=90?'warn':'crit'; const sig=dev=>{ const a24=dev.availabilityPct.h24; const w=Math.round(24*60/d.bucketMinutes); const readings=dev.traffic.slice(-w).reduce((s,v)=>s+(v||0),0); return { traffic:{ value:a24==null?null:Math.round(readings/24*10)/10+'/h', tone:a24==null?'idle':a24>=99?'ok':a24>=90?'warn':'crit' }, fresh:{ value:fmtAgo(dev.freshness.lastSeenMin), tone:dev.freshness.lastSeenMin<=dev.freshness.expectedCadenceMin?'ok':dev.freshness.lastSeenMin<=90?'warn':'crit' }, errors:{ value:dev.gaps.length+' gaps', tone:dev.gaps.length===0?'ok':dev.gaps.length<=2?'warn':'crit' }, sat:{ value:dev.battery.currentPct==null?null:dev.battery.currentPct+'%', sub:dev.battery.drainPctPerDay!=null?('-'+dev.battery.drainPctPerDay+'%/d'):undefined, tone:dev.battery.currentPct==null?'idle':dev.battery.currentPct<=25?'crit':dev.battery.currentPct<50?'warn':'ok' }, }; }; const sevCount=k=>activeAlerts.filter(a=>a.severity===k&&(!siteId||a.site===siteId)).length; // scope alert posture to this grower return
}/> {d&&(()=>{ const rows=siteId?d.devices.filter(dev=>{ const cd=DEVICES.find(x=>x.id===dev.id); return cd&&cd.site===siteId; }):d.devices; return <> {/* Alert posture moved to the top of the page (owner direction) */}
{[['critical',O.crit,O.critBg],['warning',O.warn,O.warnBg],['info',O.info,O.infoBg]].map(([k,c,bg])=>
{sevCount(k)}
{k} active
)}
burn-down history: - (alert lifecycle lands with PM-161)
{['Device','Traffic','Freshness','Errors','Saturation','SLA 24h','SLA 7d','SLA 30d'].map((h,i)=> )} {rows.map(dev=>{ const s=sig(dev); const cd=DEVICES.find(x=>x.id===dev.id); return {[s.traffic,s.fresh,s.errors,s.sat].map((c,i)=>)} {[dev.availabilityPct.h24,dev.availabilityPct.d7,dev.availabilityPct.d30].map((v,i)=> )} ;})}
{h}{(h==='Traffic'||h.startsWith('SLA'))&&}
{dev.name}
{dev.node} · {dev.kind}
onVitals(cd):undefined}/>
; })()}
; } // ================= MISSION CONTROL - FLEET LANDING ================= // Higher-level landing for the Mission Control nav: a fleet-posture metrics band + // one card per Grower. Each grower card drills into that grower's Mission Control // scorecard (HealthScorecard scoped by siteId). Reuses the same 30d trends the // scorecard already pulls -- no new API. Per-grower aggregates from DEVICES + trends. function MissionFleet({ onMission }){ const t=useTrends('30d'); const d=t.data; const sla=v=>v==null?'-':v+'%'; const slaTone=v=>v==null?'idle':v>=99?'ok':v>=90?'warn':'crit'; const sevCount=k=>activeAlerts.filter(a=>a.severity===k).length; const devOn=DEVICES.filter(x=>x.status==='online').length; return
}/> {d&&<>
0?'warn':'crit'}/> 0?'crit':'ok'}/> 0?'warn':'ok'}/> 0?'idle':'ok'}/>
{SITES.map(st=>{ const ds=DEVICES.filter(x=>x.site===st.id); const son=ds.filter(x=>x.status==='online').length; const slow=ds.filter(x=>isBattLow(x.battery)).length; const salerts=activeAlerts.filter(a=>a.site===st.id).length; const av=d.devices.filter(dev=>{ const cd=DEVICES.find(x=>x.id===dev.id); return cd&&cd.site===st.id; }).map(dev=>dev.availabilityPct.d30).filter(v=>v!=null); const avg=av.length?Math.round(av.reduce((s,v)=>s+v,0)/av.length*10)/10:null; return
onMission(st.id)} onKeyDown={e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); onMission(st.id); } }} title={'Open Mission Control - '+st.name} style={{textAlign:'left',background:O.raised,border:`1px solid ${O.line}`,borderRadius:14,padding:16}}>
{st.name}{st.kind}
{[st.crop,st.region,st.site].filter(Boolean).join(' · ')||'-'}
0?'warn':'crit'}/> 0?'warn':'ok'}/> 0?'crit':'ok'}/>
; })}
}
; } // ================= DEVICE VITALS (drill-down; deep link #/vitals/) ================= // Identity header; battery series + DERIVED drain/projection (suppressed when // the slope is noise/charging); GapTimeline; per-channel small multiples; // '-' tiles for the absent sources with their owning PM numbers. function DeviceVitals({ device }){ const t=useTrends('24h'); const d=t.data; // canon id convention matches the trends payload: gateway = uuid, leaf = node const td=d?d.devices.find(x=>x.id===(device.uuid||device.id)||x.id===device.id):null; const lastOf=v=>{ for(let i=v.length-1;i>=0;i--) if(v[i]!=null) return v[i]; return null; }; const CH_LABEL={TEMP:'Temperature',HUMID:'Humidity',AIR_PRESS:'Pressure',NOISE:'Noise',GAS_RES:'Gas (MOhm)',GW_TEMP:'Temperature',GW_HUMID:'Humidity',GW_AIR_PRESS:'Pressure'}; const CH_UNIT={TEMP:'F',HUMID:'%',AIR_PRESS:'inHg',NOISE:'dB',GAS_RES:'',GW_TEMP:'F',GW_HUMID:'%',GW_AIR_PRESS:'inHg'}; return
}/> {/* identity header (live snapshot - renders before/without trends) */}
{device.name}
{device.node} · {KIND[device.kind].label} · {siteName(device.site)} · {device.link}
{fmtAgo(device.lastSeen)} {device.battery!=null&&}
{d&&td&&<>
}/>
2?'warn':'ok'}/>
{td.battery.drainPctPerDay==null&&
projection suppressed: slope is flat/noisy or the pack is charging ({metaOf(d,'drainPctPerDay')})
}
}/>
{fmtBucketTick(d.buckets[0])}green = reporting · red = gap (hover for start/end/duration){fmtBucketTick(d.buckets[d.buckets.length-1])}
=99?'ok':availFor(td,d.range)>=90?'warn':'crit'}/>
{Object.keys(td.channels).map((key,i)=>{ const vals=td.channels[key]; const cur=lastOf(vals); const colors=[O.azure,O.teal,O.gold,O.leaf4,O.azure6]; return
{CH_LABEL[key]||key} {cur==null?'-':cur}{cur!=null&&CH_UNIT[key]?' '+CH_UNIT[key]:''}
;})} {td.kind!=='router'&&
Dew point {lastOf(td.derived.dewPointF)==null?'-':lastOf(td.derived.dewPointF)+' F'}
} {[['Signal / RSSI','PM-160'],['Firmware','PM-175'],['Air quality','PM-180']].map(([lbl,pm])=>
{lbl} - not collected yet · {pm}
)}
}
{d&&!td&&No trend entry for this device in the current payload.}
; } // ================= MISSION CONTROL (full-bleed immersive, A-skin, kiosk) ================= // Full-viewport cosmos canvas (insights.css; CSS-only starfield, no JS loops). // Center: constellation mesh map (gateway = primary star, router/sensors = // satellites from topology adjacency; glow = DS status tokens; one CSS pulse // when a device's reading arrived in the latest poll). Bottom: four golden- // signal Radials over trends 24h (refreshed every 5 min). Right: live alert // ticker (history '-' until PM-161). Fleet data rides the existing 30s poll // (OpsApp re-renders this tree). Kiosk: ?kiosk=1 hides the exit + enlarges type. function MissionControl({ onExit, onDevice, kiosk: kioskRoute }){ const kiosk=kioskRoute||/[?&]kiosk=1/.test((typeof location!=='undefined'&&location.search)||''); const [trends,setTrends]=React.useState(null); const [tErr,setTErr]=React.useState(null); React.useEffect(()=>{ let on=true; const load=()=>window.loadTrends('24h') .then(x=>{ if(on){ setTrends(x); setTErr(null); } }) .catch(e=>{ if(on) setTErr(e.code===404?'not-deployed':'error'); }); load(); const id=setInterval(load,300000); // 5-min trend refresh per spec return ()=>{ on=false; clearInterval(id); }; },[]); const devs=DEVICES; // --- constellation layout: gateways spread on the x axis, children on rings --- const W=1000,H=520; const gws=devs.filter(x=>x.kind==='gateway'); const pos=new Map(), links=[]; gws.forEach((g,gi)=>{ const cx=W*(gi+1)/(gws.length+1), cy=H*0.52; pos.set(g.id,[cx,cy]); const kids=devs.filter(x=>x.parent===g.id); kids.forEach((k,ki)=>{ const a=Math.PI*(1.15+0.7*(kids.length>1?ki/(kids.length-1):0.5)); // arc above the gateway const kx=cx+170*Math.cos(a), ky=cy+150*Math.sin(a); pos.set(k.id,[kx,ky]); links.push([g.id,k.id]); const gkids=devs.filter(x=>x.parent===k.id); gkids.forEach((s,si)=>{ const b=a+(si-(gkids.length-1)/2)*0.55; pos.set(s.id,[kx+150*Math.cos(b), ky+130*Math.sin(b)]); links.push([k.id,s.id]); }); }); }); const tone=s=>s==='online'?O.leaf4:s==='stale'?O.warn:O.crit; // --- golden signals from trends 24h + the live snapshot --- let radTraffic=null, radGaps=null, uptime=null, trendArrow=null; if(trends){ // The LAST completed bucket is the current rate - a zero there is genuine // fleet silence and must show as 0 (honesty), not reach back to older data. const ft=trends.fleet.traffic; const cur=ft.length?ft[ft.length-1]:null; const mean=trends.fleet.traffic.reduce((s,v)=>s+v,0)/Math.max(1,trends.fleet.traffic.length); radTraffic={ value:cur==null?null:Math.round(cur*(60/trends.bucketMinutes)), base:mean*(60/trends.bucketMinutes) }; trendArrow=cur==null?null:(cur>mean*1.1?1:curx.lastSeen==null?9999:x.lastSeen)); const batts=devs.filter(x=>x.battery!=null); const minBatt=batts.length?Math.min(...batts.map(x=>x.battery)):null; const steepest=trends?Math.max(0,...trends.devices.map(x=>x.battery.drainPctPerDay||0)):0; const ticker=activeAlerts; return
{/* header */}
Mission Control
{devs.filter(x=>x.status==='online').length}/{devs.length} stars reporting {tErr==='not-deployed'&&trend service not yet deployed - poll data only}
{/* constellation */}
{/* viewBox top extends to -80: with the real fleet (gateway -> router -> 2 sensors stacked upward) the grandchild stars land near y=0 and would clip without the headroom */} {links.map(([a,b],i)=>{ const pa=pos.get(a),pb=pos.get(b); if(!pa||!pb) return null; return ;})} {devs.map(dv=>{ const p=pos.get(dv.id); if(!p) return null; const c=tone(dv.status), R=dv.kind==='gateway'?13:dv.kind==='router'?9:7; const fresh=dv.lastSeen===0; // reading arrived in the latest poll (one pulse per arrival: mounts at 0, unmounts at 1) return onDevice&&onDevice(dv)} style={{cursor:'pointer'}}> {fresh&&} {dv.name} {dv.kind==='gateway'?'LTE uplink':dv.node} ;})}
{/* alert ticker rail */}
Live alerts
{ticker.length===0 ?
All quiet
24h availability {uptime==null?'-':uptime+'%'}
: ticker.map(a=>{ const c=a.severity==='critical'?O.crit:a.severity==='warning'?O.warn:O.info; return
{a.title}
{siteName(a.site)}{a.node?' · '+a.node:''}
;})}
alert history: - (PM-161)
{/* golden-signal radials */}
0?Math.min(1,radTraffic.value/(radTraffic.base*1.5)):null} tone={radTraffic&&radTraffic.value!=null?(radTraffic.value>=radTraffic.base*0.6?'ok':radTraffic.value>=radTraffic.base*0.3?'warn':'crit'):'ok'} trend={trendArrow} sub="vs 24h baseline"/> 0?('steepest -'+steepest+'%/d'):'drain -'}/>
; } Object.assign(window,{ FleetRhythms, Microclimate, HealthScorecard, MissionFleet, DeviceVitals, MissionControl, useTrends });