/* Bee Smart Operations Portal - vNext canon prototype: LIVE fleet data. SITES + DEVICES are fetched at runtime from /api/fleet-snapshot (the real Next data layer: DATA_SOURCE=grafana -> Grafana proxy -> InfluxDB, adapted to this flat shape by lib/data/canon-adapter.ts). NO fabricated values: anything the portal does not collect is `null` and renders `-` in the UI. (s362, PM-191 -- replaces the baked 2026-06-06 InfluxDB snapshot with live data; the app boots through loading/empty/error states while the fetch resolves.) NOT collected by the portal today (-> null -> `-`), each owned by a tracked PM: rssi/Signal (PM-160) · firmware (PM-175) · acres/crop/region (no tenant-meta store) · AQI/air-quality (gas is frozen, PM-180) · per-card integration health (PM-159) */ // Production flag: when this canon is served as the live portal (production // index.html sets window.PORTAL_PROD=true; or preview with ?prod=1), the // prototype-only chrome (the vNext ribbon + the per-page ReviewNote callouts) // is hidden. Everything else renders pixel-identical. The canon + /next leave // the flag unset so the sign-off chrome stays visible. const PROD_PORTAL = (typeof window !== 'undefined') && ( window.PORTAL_PROD === true || /[?&]prod=1/.test((typeof location !== 'undefined' && location.search) || '') || // auto: served as the production ROOT (not the /next sign-off surface, not localhost) (typeof location !== 'undefined' && location.hostname === 'portal.tbdcloud.com' && !location.pathname.startsWith('/next')) ); // Production root keeps the static neutral tab title ("Bee Smart Operations"); the // /next sign-off surface + local preview re-label themselves as the canon prototype. if (typeof document !== 'undefined' && !PROD_PORTAL) document.title = 'Bee Smart Operations - vNext Canon'; // Deployments (the kit calls these SITES; each is one grower/retail customer + // site). Populated by loadFleet() from /api/fleet-snapshot; empty until then. let SITES = []; function fmtAgo(min){ if(min==null) return '-'; if(min<1) return 'just now'; if(min<60) return min+'m ago'; const h=Math.floor(min/60); if(h<24) return h+'h ago'; return Math.floor(h/24)+'d ago'; } // Real fleet, fetched live from /api/fleet-snapshot (lib/data -> InfluxDB, adapted // by canon-adapter.ts). reading.gas is the MOhm magnitude; air-quality (aqi) stays // `-` until PM-180 (frozen gas) + PM-004 (IAQ) land. reading.noise IS live but the // UI renders it per the s353 plan default. Empty until loadFleet() resolves. let DEVICES = []; // Fleet-wide telemetry count over the trailing 24h (canon-adapter readings24h = // sum of the listSeries window counts). null until loadFleet() resolves / on a // degraded series query -> the Overview "Readings (24h)" card renders "-". let READINGS24H = null; // Per-30m-bucket fleet trend over -24h (canon-adapter `series`): [{messages, devices}]. // Feeds the Overview KPI sparklines. Empty until loadFleet() resolves / on a degraded // series -> the sparklines render a flat baseline. let SERIES = []; const siteName = id => (SITES.find(s=>s.id===id)||{}).name || id; const siteOf = id => SITES.find(s=>s.id===id); function counts(){ const by={ gateway:{on:0,t:0}, router:{on:0,t:0}, sensor:{on:0,t:0} }; DEVICES.forEach(d=>{ by[d.kind].t++; if(d.status==='online') by[d.kind].on++; }); return by; } // COUNTS + onlineCount are (re)computed by publish() once DEVICES is populated. // Alerts are DERIVED from the real snapshot (deriveAlerts; the production rule). Any // device that is offline raises a Critical alert; any device at <=25% battery raises a // Warning. Right now the only real condition is G2 (retail standalone) being dark -> one // active Critical. The G1 mesh is fresh and its batteries are healthy (48/52/96%), so it // raises nothing. No fabricated feed -- every alert maps to a real device state. function deriveAlerts(){ const out=[]; DEVICES.filter(d=>d.status==='offline').forEach(d=>out.push({ id:'off-'+d.id, severity:'critical', status:'active', title:d.name+' offline', detail:'No telemetry from '+d.name+' at '+siteName(d.site)+' (last seen '+fmtAgo(d.lastSeen)+').', site:d.site, node:d.node })); DEVICES.filter(d=>d.battery!=null&&d.battery>0&&d.battery<=25).forEach(d=>out.push({ id:'low-'+d.id, severity:'warning', status:'active', title:d.name+' battery low ('+d.battery+'%)', detail:d.name+' at '+siteName(d.site)+' is at '+d.battery+'%.', site:d.site, node:d.node })); return out; } // ALERTS + activeAlerts are (re)computed by publish() once DEVICES is populated. // Real integrations behind this deployment = the PLATFORM. status: 'up'|'degraded'|'down'| // null(not probed). We can VERIFY the live data path right now -- this portal is served by // Caddy on the UpCloud VM and this snapshot was pulled THROUGH that VM from InfluxDB with // Grafana proxying it -- so UpCloud / InfluxDB / Grafana are known up at snapshot time. // Soracom (LTE/SIM) and nRF Cloud are not actively probed yet -> null -> '-' until live // per-component health lands (PM-159). No fake "Operational" for the un-probed ones. const INTEGRATIONS = [ { id:'upcloud', name:'UpCloud', role:'Cloud Infrastructure (VM 85.9.197.106)', status:'up', note:'Verified - serving this portal', icon:'server', url:'https://hub.upcloud.com' }, { id:'influxdb', name:'InfluxDB', role:'Time-Series Telemetry Store', status:'up', note:'Verified - snapshot source', icon:'database', url:null }, { id:'grafana', name:'Grafana', role:'Metrics & Dashboards', status:'up', note:'Verified - proxy live', icon:'bar-chart-3', url:'https://dashboards.tbdcloud.com' }, { id:'nrf', name:'nRF Cloud', role:'Device Firmware & DFU', status:null, note:null, icon:'cpu', url:'https://nrfcloud.com' }, { id:'soracom', name:'Soracom', role:'LTE / SIM Management', status:null, note:null, icon:'signal', url:'https://console.soracom.io' }, ]; // Platform status = the worst per-component health. Any 'down' -> red Outage; any 'degraded' // -> amber Degraded; otherwise green Operational. Un-probed (null) components never block // green (verified-up, green-if-none-down -- s358 owner ruling). function derivePlatform(){ const st=INTEGRATIONS.map(i=>i.status); if(st.includes('down')) return { state:'down', label:'systems down', tone:'crit' }; if(st.includes('degraded')) return { state:'degraded', label:'systems degraded', tone:'warn' }; return { state:'up', label:'all systems nominal', tone:'ok' }; } const PLATFORM = derivePlatform(); // A site with no router AND no sensors is a standalone gateway (retail) -- no real mesh, so // its "mesh path" is just Gateway -> Cloud and the "Mesh Path" label is dropped (s358). const isStandalone = id => { const ds=DEVICES.filter(d=>d.site===id); return !ds.some(d=>d.kind==='router')&&!ds.some(d=>d.kind==='sensor'); }; // --- Live data boot -------------------------------------------------------- // The static prototype baked SITES/DEVICES in; the production canon fetches them // from /api/fleet-snapshot. In production Caddy proxies /api/* -> the Next app on // :3001, so the path is same-origin. For local preview the static files and the // Next dev API live on different origins, so an apiBase override is supported: // window.FLEET_API_BASE = 'http://localhost:3000' or ?apiBase=http://localhost:3000 function fleetApiBase(){ if(typeof window!=='undefined' && window.FLEET_API_BASE) return window.FLEET_API_BASE; const m=/[?&]apiBase=([^&]+)/.exec((typeof location!=='undefined' && location.search) || ''); return m ? decodeURIComponent(m[1]) : ''; } // Publish the static + derived globals with the CURRENT SITES/DEVICES. Called once // up front with safe empty defaults (so any render before the fetch resolves never // hits an undefined global) and again by loadFleet() after live data lands. function publish(){ const ALERTS = deriveAlerts(); Object.assign(window, { SITES, DEVICES, READINGS24H, SERIES, COUNTS: counts(), onlineCount: DEVICES.filter(d=>d.status==='online').length, ALERTS, activeAlerts: ALERTS.filter(a=>a.status==='active'), siteName, siteOf, fmtAgo, INTEGRATIONS, PLATFORM, isStandalone, }); } publish(); // Fetch live SITES + DEVICES. Resolves on success (app.jsx flips to 'ready'); // throws on a non-2xx / network failure (app.jsx shows the error state + retry). window.loadFleet = async function(){ const res = await fetch(fleetApiBase()+'/api/fleet-snapshot', { cache:'no-store' }); if(!res.ok) throw new Error('fleet-snapshot HTTP '+res.status); const data = await res.json(); SITES = Array.isArray(data.sites) ? data.sites : []; DEVICES = Array.isArray(data.devices) ? data.devices : []; READINGS24H = (typeof data.readings24h === 'number') ? data.readings24h : null; SERIES = Array.isArray(data.series) ? data.series : []; publish(); return data; }; // Admin-gated grower edit (PM-184). PATCHes /api/growers/[id] (id = customer_id) with // the operator's admin token (localStorage; never persisted server-side from here). // Throws Error with .code= (401 on a bad token -> the modal re-prompts). // Same fleetApiBase() origin rule as loadFleet (same-origin in prod). window.patchGrower = async function(id, fields, token){ const res = await fetch(fleetApiBase()+'/api/growers/'+encodeURIComponent(id), { method:'PATCH', headers:{ 'Content-Type':'application/json', 'x-admin-token': token }, body: JSON.stringify(fields), }); if(!res.ok){ const e=new Error('grower PATCH HTTP '+res.status); e.code=res.status; throw e; } return res.json(); };