/* 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)
;
}
// ================= 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) */}
;
}
// ================= 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&&!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 */}