demeter

Autonomous Hydroponic Intelligence
commit c7ef3924cce81c4dc65378794623a2f238b02279
parent 782803d81fe1396e74c3d7990f5c2f99569a8d0b
Author: maydayv7 <maydayv7@gmail.com>
Date:   Thu, 19 Mar 2026 22:49:46 +0530

Optimize Frontend

Diffstat:
Mfrontend/src/App.js | 24+++++++++++++-----------
Mfrontend/src/api/agentApi.js | 7++++++-
Mfrontend/src/components/Sidebar.jsx | 39+++++++++++----------------------------
Afrontend/src/hooks/useFarmData.js | 38++++++++++++++++++++++++++++++++++++++
Mfrontend/src/pages/AgentControl.jsx | 26++++++++++++++++++++++----
Mfrontend/src/pages/Alerts.jsx | 30++++++++++--------------------
Mfrontend/src/pages/Analytics.jsx | 57+++++++++++++++++++--------------------------------------
Mfrontend/src/pages/CropDetails.jsx | 7+++----
Mfrontend/src/pages/Dashboard.jsx | 44+++++++++++++++-----------------------------
Mfrontend/src/pages/LandingPage.jsx | 47+++++++++++++++++++++--------------------------
Mfrontend/src/utils/dataUtils.js | 58++++++++++++++++++++++++++++++++++++++++++++++++++--------
11 files changed, 208 insertions(+), 169 deletions(-)

diff --git a/frontend/src/App.js b/frontend/src/App.js @@ -1,5 +1,5 @@ -import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import { FarmDataProvider } from "./hooks/useFarmData"; import LandingPage from "./pages/LandingPage"; import Dashboard from "./pages/Dashboard"; @@ -10,16 +10,18 @@ import Alerts from "./pages/Alerts"; function App() { return ( - <Router> - <Routes> - <Route path="/" element={<LandingPage />} /> - <Route path="/control" element={<AgentControl />} /> - <Route path="/dashboard" element={<Dashboard />} /> - <Route path="/crop/:cropId" element={<CropDetails />} /> - <Route path="/analytics" element={<Analytics />} /> - <Route path="/alerts" element={<Alerts />} /> - </Routes> - </Router> + <FarmDataProvider> + <Router> + <Routes> + <Route path="/" element={<LandingPage />} /> + <Route path="/control" element={<AgentControl />} /> + <Route path="/dashboard" element={<Dashboard />} /> + <Route path="/crop/:cropId" element={<CropDetails />} /> + <Route path="/analytics" element={<Analytics />} /> + <Route path="/alerts" element={<Alerts />} /> + </Routes> + </Router> + </FarmDataProvider> ); } diff --git a/frontend/src/api/agentApi.js b/frontend/src/api/agentApi.js @@ -14,11 +14,16 @@ export const agentService = { EC: parseFloat(sensors.EC), temp: parseFloat(sensors.temp), humidity: parseFloat(sensors.humidity), + crop_id: sensors.crop_id || undefined, }), ); formData.append( "metadata", - JSON.stringify({ crop: sensors.crop, stage: sensors.stage }), + JSON.stringify({ + crop: sensors.crop, + stage: sensors.stage, + crop_id: sensors.crop_id || undefined, + }), ); const res = await fetch(`${API_URL}/ingest`, { method: "POST", diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useMemo } from "react"; import { Link, useLocation } from "react-router-dom"; import { Leaf, @@ -11,38 +11,21 @@ import { ChevronRight, Zap, } from "lucide-react"; -import { fetchDashboardData } from "../api/farmApi"; -import { extractSensors } from "../utils/dataUtils"; - -function countLiveAlerts(dashData) { - if (!dashData?.length) return 0; - return dashData.filter((d) => { - const s = extractSensors(d.payload); - const ph = parseFloat(s.ph); - const ec = parseFloat(s.ec); - const temp = parseFloat(s.temp); - const outcome = (d.payload?.outcome || "").toLowerCase(); - return ( - ph < 5.0 || - ph > 7.5 || - ec > 3.0 || - temp > 34 || - (temp > 0 && temp < 12) || - /fail|critical|disease|pest|fungal/.test(outcome) - ); - }).length; -} +import { useFarmData } from "../hooks/useFarmData"; +import { deriveCropStatus } from "../utils/dataUtils"; export default function Sidebar() { const [collapsed, setCollapsed] = useState(false); - const [alertCount, setAlertCount] = useState(0); const loc = useLocation(); + const { dashboard } = useFarmData(); - useEffect(() => { - fetchDashboardData().then((data) => { - setAlertCount(countLiveAlerts(data)); - }); - }, []); + const alertCount = useMemo(() => { + if (!dashboard?.length) return 0; + return dashboard.filter((d) => { + const status = deriveCropStatus(d.payload); + return status === "Critical" || status === "Attention"; + }).length; + }, [dashboard]); const NAV = [ { label: "Crops", icon: LayoutGrid, path: "/dashboard" }, diff --git a/frontend/src/hooks/useFarmData.js b/frontend/src/hooks/useFarmData.js @@ -0,0 +1,38 @@ +import React, { useState, useEffect, createContext, useContext } from "react"; +import { fetchDashboardData, fetchAllCropHistories } from "../api/farmApi"; + +const FarmDataContext = createContext(); + +export const FarmDataProvider = ({ children }) => { + const [dashboard, setDashboard] = useState([]); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + + const refreshData = async () => { + setLoading(true); + try { + const dash = await fetchDashboardData(); + setDashboard(dash || []); + const hist = await fetchAllCropHistories(dash || []); + setHistory(hist || []); + } catch (error) { + console.error("Failed to fetch farm data", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + refreshData(); + }, []); + + return ( + <FarmDataContext.Provider + value={{ dashboard, history, loading, refreshData }} + > + {children} + </FarmDataContext.Provider> + ); +}; + +export const useFarmData = () => useContext(FarmDataContext); diff --git a/frontend/src/pages/AgentControl.jsx b/frontend/src/pages/AgentControl.jsx @@ -54,7 +54,7 @@ const INPUT_FIELDS = [ type: "number", }, { - label: "Crop", + label: "Crop Type", name: "crop", icon: Sprout, color: "var(--green)", @@ -69,6 +69,14 @@ const INPUT_FIELDS = [ type: "select", opts: ["Seedling", "Vegetative", "Flowering", "Fruiting"], }, + { + label: "Crop ID (Opt)", + name: "crop_id", + icon: Database, + color: "var(--text-3)", + type: "text", + placeholder: "e.g., Batch_A1", + }, ]; const ACTION_MAP = { @@ -131,6 +139,7 @@ export default function AgentControl() { humidity: "60", crop: "Lettuce", stage: "Vegetative", + crop_id: "", }); const showToast = (msg, type = "success") => { @@ -445,7 +454,15 @@ export default function AgentControl() { </div> <div className="grid grid-cols-2 gap-4"> {INPUT_FIELDS.map( - ({ label, name, icon: Icon, color, type, opts }) => ( + ({ + label, + name, + icon: Icon, + color, + type, + opts, + placeholder, + }) => ( <div key={name}> <label className="text-[10px] font-mono mb-1 block" @@ -492,8 +509,9 @@ export default function AgentControl() { onChange={(e) => setSensors({ ...sensors, [name]: e.target.value }) } - type="number" - step="0.1" + type={type} + placeholder={placeholder || ""} + step={type === "number" ? "0.1" : undefined} className="w-full pl-8 pr-3 py-2.5 rounded-lg text-sm font-mono outline-none" style={{ background: "var(--bg-3)", diff --git a/frontend/src/pages/Alerts.jsx b/frontend/src/pages/Alerts.jsx @@ -10,7 +10,7 @@ import { Clock, RefreshCw, } from "lucide-react"; -import { fetchDashboardData, fetchAllCropHistories } from "../api/farmApi"; +import { useFarmData } from "../hooks/useFarmData"; import { extractSensors, formatOutcome } from "../utils/dataUtils"; import Sidebar from "../components/Sidebar"; @@ -246,9 +246,7 @@ function generateAlerts(points) { id: id++, severity: "info", title: `Cycle #${seq} completed`, - desc: `${cropName} (${cropId}): Sequence stored. ${ - strategy ? `Strategy: ${strategy}.` : "" - } ${payload.reward_score != null ? `Reward: ${payload.reward_score}` : ""}`.trim(), + desc: `${cropName} (${cropId}): Sequence stored. ${strategy ? `Strategy: ${strategy}.` : ""} ${payload.reward_score != null ? `Reward: ${payload.reward_score}` : ""}`.trim(), time: timeAgo(ts), ts, agent: strategy ? "SUPERVISOR" : "JUDGE", @@ -258,19 +256,18 @@ function generateAlerts(points) { } } - // De-duplicate: keep at most 3 alerts of same severity+title+crop + // Deduplicate: limit to 2 for cleaner UI const seen = new Map(); const deduped = []; for (const a of alerts) { const key = `${a.severity}|${a.title}|${a.crop}`; const count = seen.get(key) || 0; - if (count < 3) { + if (count < 2) { deduped.push(a); seen.set(key, count + 1); } } - // Sort: critical first, then by time descending deduped.sort((a, b) => { const sevOrder = { critical: 0, warning: 1, info: 2 }; if (sevOrder[a.severity] !== sevOrder[b.severity]) @@ -423,23 +420,16 @@ function AlertCard({ alert, onAck, onDismiss }) { // MAIN export default function Alerts() { + const { history, loading, refreshData } = useFarmData(); const [alerts, setAlerts] = useState([]); - const [loading, setLoading] = useState(true); const [filter, setFilter] = useState("all"); const [showAcked, setShowAcked] = useState(false); - const load = async () => { - setLoading(true); - const dash = await fetchDashboardData(); - const hist = await fetchAllCropHistories(dash); - const generated = generateAlerts(hist); - setAlerts(generated); - setLoading(false); - }; - useEffect(() => { - load(); - }, []); + if (!loading) { + setAlerts(generateAlerts(history)); + } + }, [history, loading]); const ack = (id) => setAlerts((a) => a.map((al) => (al.id === id ? { ...al, ack: true } : al))); @@ -491,7 +481,7 @@ export default function Alerts() { </div> <div className="ml-auto flex items-center gap-2"> <button - onClick={load} + onClick={refreshData} className="p-2 rounded-lg transition-colors" style={{ background: "var(--surface)", diff --git a/frontend/src/pages/Analytics.jsx b/frontend/src/pages/Analytics.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useMemo } from "react"; import { AreaChart, Area, @@ -16,7 +16,7 @@ import { PolarGrid, PolarAngleAxis, } from "recharts"; -import { fetchDashboardData, fetchAllCropHistories } from "../api/farmApi"; +import { useFarmData } from "../hooks/useFarmData"; import { extractSensors } from "../utils/dataUtils"; import { TrendingUp, TrendingDown, Minus, Download } from "lucide-react"; import Sidebar from "../components/Sidebar"; @@ -48,11 +48,9 @@ function bucketHistory(points, range) { }; const cutoff = now - (MS[range] || MS["24h"]); - const filtered = points.filter((p) => { - const t = new Date(p.payload?.timestamp || 0).getTime(); - return t >= cutoff; - }); - + const filtered = points.filter( + (p) => new Date(p.payload?.timestamp || 0).getTime() >= cutoff, + ); if (!filtered.length) return []; // Build buckets @@ -119,7 +117,7 @@ function dailyCropActivity(points) { })); } -// Build radar: how close each param is to ideal range +// How close each param is to ideal range function buildRadar(points) { if (!points.length) return []; const sensors = points.map((p) => extractSensors(p.payload)); @@ -178,7 +176,6 @@ function buildAgentStats(points) { for (const agent of AGENTS) { if (action.toUpperCase().includes(agent)) counts[agent]++; } - // every stored point = at least one supervisor decision counts["SUPERVISOR"]++; } @@ -189,14 +186,12 @@ function buildAgentStats(points) { // accuracy = % of points where outcome is NOT negative accuracy: points.length ? Math.round( - (points.filter((p) => { - const o = (p.payload?.outcome || "").toLowerCase(); - return ( - !o.includes("fail") && - !o.includes("negative") && - !o.includes("critical") - ); - }).length / + (points.filter( + (p) => + !/fail|negative|critical/.test( + (p.payload?.outcome || "").toLowerCase(), + ), + ).length / total) * 100, ) @@ -317,21 +312,7 @@ function EmptyChart({ height = 180, message = "No data yet" }) { export default function Analytics() { const [range, setRange] = useState("24h"); - const [loading, setLoading] = useState(true); - const [allPoints, setAllPoints] = useState([]); // every stored point from all crops - const [dashboard, setDashboard] = useState([]); // latest snapshot per crop - - useEffect(() => { - setLoading(true); - fetchDashboardData().then(async (dash) => { - setDashboard(dash || []); - const hist = await fetchAllCropHistories(dash); - setAllPoints(hist); - setLoading(false); - }); - }, []); - - // STATS + const { dashboard, history: allPoints, loading } = useFarmData(); const buckets = useMemo( () => bucketHistory(allPoints, range), @@ -392,7 +373,7 @@ export default function Analytics() { > <Sidebar /> <main className="flex-1 flex flex-col overflow-hidden"> - {/* ── Header ── */} + {/* Header */} <header className="flex-shrink-0 px-6 py-4 border-b flex items-center gap-4" style={{ borderColor: "var(--border)", background: "var(--bg-2)" }} @@ -438,7 +419,7 @@ export default function Analytics() { </header> <div className="flex-1 overflow-y-auto p-6 space-y-8"> - {/* ── Metric cards ── */} + {/* Metric cards */} <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <MetricCard loading={loading} @@ -477,7 +458,7 @@ export default function Analytics() { /> </div> - {/* ── pH + EC ── */} + {/* pH + EC */} <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="rounded-xl p-5" @@ -626,7 +607,7 @@ export default function Analytics() { </div> </div> - {/* ── Temp + Humidity ── */} + {/* Temp + Humidity */} <div className="rounded-xl p-5" style={{ @@ -708,7 +689,7 @@ export default function Analytics() { )} </div> - {/* ── Activity bar + Radar ── */} + {/* Activity bar + Radar */} <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="rounded-xl p-5" @@ -805,7 +786,7 @@ export default function Analytics() { </div> </div> - {/* ── Crop breakdown table ── */} + {/* Crop breakdown table */} <div className="rounded-xl overflow-hidden" style={{ diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx @@ -104,10 +104,9 @@ export default function CropDetails() { ); const processed = sorted.map((item) => { const p = item.payload || {}; - const sensors = extractSensors(p); return { ...item, - cleanSensors: sensors, + cleanSensors: extractSensors(p), parsedAction: parsePythonString(p.action_taken), }; }); @@ -211,7 +210,7 @@ export default function CropDetails() { <span className="status-dot w-1.5 h-1.5 rounded-full" style={{ background: "var(--green)" }} - /> + />{" "} LIVE </div> @@ -316,7 +315,7 @@ export default function CropDetails() { tickLine={false} /> <YAxis - domain={[5, 7.5]} + domain={["auto", "auto"]} tick={{ fontSize: 9, fill: "var(--text-3)", diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx @@ -1,7 +1,11 @@ import React, { useState, useEffect, useMemo } from "react"; import { useNavigate } from "react-router-dom"; -import { fetchDashboardData } from "../api/farmApi"; -import { extractSensors, calculateMaturity } from "../utils/dataUtils"; +import { useFarmData } from "../hooks/useFarmData"; +import { + extractSensors, + calculateMaturity, + deriveCropStatus, +} from "../utils/dataUtils"; import { Search, SlidersHorizontal, @@ -191,21 +195,18 @@ function CropCard({ data, onClick }) { export default function Dashboard() { const navigate = useNavigate(); + const { dashboard, loading, refreshData } = useFarmData(); const [crops, setCrops] = useState([]); - const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [filterStage, setFilterStage] = useState("All"); const [filterCrop, setFilterCrop] = useState("All"); const [filterStatus, setFilterStatus] = useState("All"); const [showFilters, setShowFilters] = useState(false); - const [refreshing, setRefreshing] = useState(false); - const loadData = async () => { - setRefreshing(true); - const data = await fetchDashboardData(); - if (data?.length > 0) { + useEffect(() => { + if (dashboard) { setCrops( - data.map((item) => { + dashboard.map((item) => { const p = item.payload || {}; const sensors = extractSensors(p); return { @@ -214,12 +215,7 @@ export default function Dashboard() { name: p.crop || "Unknown", statusMsg: p.stage || "Growing", image: getImg(p.crop), - status: - p.outcome === "CRITICAL" - ? "Critical" - : p.action_taken === "PENDING_ACTION" - ? "Attention" - : "Healthy", + status: deriveCropStatus(p), maturity: calculateMaturity(p.sequence_number), seq: p.sequence_number, daysLeft: 30 - (p.sequence_number || 0), @@ -230,13 +226,7 @@ export default function Dashboard() { }), ); } - setLoading(false); - setRefreshing(false); - }; - - useEffect(() => { - loadData(); - }, []); + }, [dashboard]); const getImg = (name) => { if (!name) return null; @@ -339,7 +329,7 @@ export default function Dashboard() { <div className="ml-auto flex items-center gap-2"> <button - onClick={loadData} + onClick={refreshData} className="p-2 rounded-lg transition-colors" style={{ background: "var(--surface)", @@ -347,10 +337,7 @@ export default function Dashboard() { color: "var(--text-3)", }} > - <RefreshCw - size={15} - className={refreshing ? "animate-spin" : ""} - /> + <RefreshCw size={15} className={loading ? "animate-spin" : ""} /> </button> </div> </header> @@ -401,8 +388,7 @@ export default function Dashboard() { color: showFilters ? "var(--green)" : "var(--text-2)", }} > - <SlidersHorizontal size={14} /> - Filters + <SlidersHorizontal size={14} /> Filters {activeFilters > 0 && ( <span className="px-1.5 py-0.5 rounded text-[10px] font-mono" diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx @@ -9,8 +9,8 @@ import { Zap, Activity, } from "lucide-react"; -import { fetchDashboardData } from "../api/farmApi"; -import { extractSensors } from "../utils/dataUtils"; +import { useFarmData } from "../hooks/useFarmData"; +import { extractSensors, deriveCropStatus } from "../utils/dataUtils"; const FEATURES = [ { @@ -55,15 +55,10 @@ function computeFleetStats(dashData) { ...new Set(dashData.map((d) => d.payload?.crop).filter(Boolean)), ]; - // Any active alert conditions? - const alerts = sensors.filter( - (s) => - parseFloat(s.ph) < 5.5 || - parseFloat(s.ph) > 6.5 || - parseFloat(s.ec) > 2.5 || - parseFloat(s.temp) > 30 || - parseFloat(s.temp) < 15, - ).length; + const alerts = dashData.filter((d) => { + const status = deriveCropStatus(d.payload); + return status === "Critical" || status === "Attention"; + }).length; return { ph: ph.toFixed(2), @@ -77,10 +72,9 @@ function computeFleetStats(dashData) { }; } -// Build a real activity log from the last N payloads -function buildActivityLog(dashData) { - if (!dashData?.length) return []; - return dashData +function buildActivityLog(historyData) { + if (!historyData?.length) return []; + return historyData .slice(-5) .reverse() .map((d, i) => { @@ -104,20 +98,22 @@ function buildActivityLog(dashData) { export default function LandingPage() { const navigate = useNavigate(); const [mounted, setMounted] = useState(false); + const { dashboard, history, loading } = useFarmData(); + const [stats, setStats] = useState(null); const [log, setLog] = useState([]); - const [loading, setLoading] = useState(true); useEffect(() => { setMounted(true); - fetchDashboardData().then((data) => { - setStats(computeFleetStats(data)); - setLog(buildActivityLog(data)); - setLoading(false); - }); }, []); - // Headline stats derived from real data, with friendly fallbacks + useEffect(() => { + if (!loading) { + setStats(computeFleetStats(dashboard)); + setLog(buildActivityLog(history)); + } + }, [dashboard, history, loading]); + const headlineStats = stats ? [ { val: stats.cropCount.toString(), label: "Active Crops" }, @@ -135,7 +131,7 @@ export default function LandingPage() { { val: "—", label: "Active Alerts" }, ]; - // Live sensor readings from real data + // Live sensor readings const readings = stats ? [ { @@ -291,7 +287,7 @@ export default function LandingPage() { className="group flex items-center gap-3 px-7 py-3.5 rounded-xl font-semibold text-sm transition-all glow-green" style={{ background: "var(--green)", color: "#0c1a0e" }} > - Enter Dashboard + Enter Dashboard{" "} <ArrowRight size={16} className="group-hover:translate-x-1 transition-transform" @@ -306,8 +302,7 @@ export default function LandingPage() { background: "var(--surface)", }} > - <Cpu size={16} /> - Agent Control + <Cpu size={16} /> Agent Control </button> </div> diff --git a/frontend/src/utils/dataUtils.js b/frontend/src/utils/dataUtils.js @@ -39,21 +39,24 @@ export const extractSensors = (payload) => { } } return { - temp: formatNumber(raw.temp ?? 0), - ph: formatNumber(raw.pH ?? 7.0), - humidity: formatNumber(raw.humidity ?? 0), - ec: formatNumber(raw.EC ?? 0), + temp: formatNumber(raw.temp ?? raw.Temp ?? 0), + ph: formatNumber(raw.ph ?? raw.pH ?? 7.0), + humidity: formatNumber(raw.humidity ?? raw.Humidity ?? 0), + ec: formatNumber(raw.ec ?? raw.EC ?? 0), }; }; export const calculateMaturity = (seq) => { - const val = (seq || 1) * 10; - return val > 100 ? 100 : val; + return Math.min((seq || 1) * 10, 100); }; export const formatOutcome = (outcome) => { if (!outcome || typeof outcome !== "string") return "Monitoring..."; - const parts = outcome.split("|").map((p) => p.trim()); + + // Strip reward suffix + const cleanOutcome = outcome.split("| Reward:")[0].trim(); + + const parts = cleanOutcome.split("|").map((p) => p.trim()); let tags = [], notes = ""; parts.forEach((part) => { @@ -69,7 +72,46 @@ export const formatOutcome = (outcome) => { tags.push(part); } }); - if (!tags.length && !notes) return outcome; + if (!tags.length && !notes) return cleanOutcome; const t = tags.join(" · "); return t && notes ? `${t} — ${notes}` : t || notes; }; + +// Crop health status +export const deriveCropStatus = (payload) => { + if (!payload) return "Healthy"; + const s = extractSensors(payload); + const ph = parseFloat(s.ph) || 0; + const ec = parseFloat(s.ec) || 0; + const temp = parseFloat(s.temp) || 0; + const outcome = (payload.outcome || "").toLowerCase(); + const action = (payload.action_taken || "").toUpperCase(); + + // Critical Thresholds + if ( + (ph > 0 && ph < 4.5) || + ph > 7.5 || + ec > 3.5 || + (temp > 0 && temp < 10) || + temp > 35 || + /fail|critical|disease|error/.test(outcome) || + /DISEASE|FUNGAL|PEST/.test(action) + ) { + return "Critical"; + } + + // Warning/Attention Thresholds + if ( + (ph > 0 && ph < 5.5) || + ph > 6.5 || + ec > 2.5 || + (temp > 0 && temp < 17) || + temp > 30 || + /deteriorat|negative|attention|decline/.test(outcome) || + /FLUSH|PRUNE|BOOST/.test(action) + ) { + return "Attention"; + } + + return "Healthy"; +};