demeter

Autonomous Hydroponic Intelligence
commit 782803d81fe1396e74c3d7990f5c2f99569a8d0b
parent d10e11cc5aa17727387739e65731e488fe858f67
Author: maydayv7 <maydayv7@gmail.com>
Date:   Thu, 19 Mar 2026 19:07:42 +0530

Revamp Frontend

Diffstat:
Mfrontend/src/App.js | 25+++++++++++--------------
Mfrontend/src/api/agentApi.js | 103++++++++++++++++++++++++++++++++++++-------------------------------------------
Mfrontend/src/api/farmApi.jsx | 51+++++++++++++++++++++++++++++++++++----------------
Afrontend/src/components/Sidebar.jsx | 255+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mfrontend/src/index.css | 292++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mfrontend/src/pages/AgentControl.jsx | 1034+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Afrontend/src/pages/Alerts.jsx | 670+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afrontend/src/pages/Analytics.jsx | 1023+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mfrontend/src/pages/CropDetails.jsx | 801++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mfrontend/src/pages/Dashboard.jsx | 699++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mfrontend/src/pages/LandingPage.jsx | 655++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mfrontend/src/utils/dataUtils.js | 72+++++++++++++++++++++++++++---------------------------------------------
Mfrontend/tailwind.config.js | 28++++++++++++++++++++--------
13 files changed, 4472 insertions(+), 1236 deletions(-)

diff --git a/frontend/src/App.js b/frontend/src/App.js @@ -1,28 +1,26 @@ -import React from 'react'; -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import React from "react"; +import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; -// Pages -import LandingPage from './pages/LandingPage'; -import Dashboard from './pages/Dashboard'; -import CropDetails from './pages/CropDetails'; -import AgentControl from './pages/AgentControl'; +import LandingPage from "./pages/LandingPage"; +import Dashboard from "./pages/Dashboard"; +import CropDetails from "./pages/CropDetails"; +import AgentControl from "./pages/AgentControl"; +import Analytics from "./pages/Analytics"; +import Alerts from "./pages/Alerts"; function App() { return ( <Router> <Routes> - {/* Public "Web" Landing Page */} <Route path="/" element={<LandingPage />} /> - - {/* The "Brain" Control Panel (Python Agent) */} <Route path="/control" element={<AgentControl />} /> - - {/* The "CRUD" Dashboard (Node.js Backend) */} <Route path="/dashboard" element={<Dashboard />} /> <Route path="/crop/:cropId" element={<CropDetails />} /> + <Route path="/analytics" element={<Analytics />} /> + <Route path="/alerts" element={<Alerts />} /> </Routes> </Router> ); } -export default App; -\ No newline at end of file +export default App; diff --git a/frontend/src/api/agentApi.js b/frontend/src/api/agentApi.js @@ -1,5 +1,3 @@ -// src/api/agentApi.js - const API_URL = "http://localhost:8000"; export const agentService = { @@ -7,27 +5,27 @@ export const agentService = { * Uploads an image + sensors to create a new FMU (Functional Memory Unit) */ async uploadFMU(file, sensors) { - const formData = new FormData(); - formData.append("file", file); - formData.append("sensors", JSON.stringify({ - pH: parseFloat(sensors.pH), - EC: parseFloat(sensors.EC), - temp: parseFloat(sensors.temp), - humidity: parseFloat(sensors.humidity) - })); - formData.append("metadata", JSON.stringify({ - crop: sensors.crop, - stage: sensors.stage - })); - - try { - const res = await fetch(`${API_URL}/ingest`, { method: "POST", body: formData }); - if (!res.ok) throw new Error(`Server Error: ${res.statusText}`); - return await res.json(); - } catch (error) { - console.error("Ingest Service Error:", error); - throw error; - } + const formData = new FormData(); + formData.append("file", file); + formData.append( + "sensors", + JSON.stringify({ + pH: parseFloat(sensors.pH), + EC: parseFloat(sensors.EC), + temp: parseFloat(sensors.temp), + humidity: parseFloat(sensors.humidity), + }), + ); + formData.append( + "metadata", + JSON.stringify({ crop: sensors.crop, stage: sensors.stage }), + ); + const res = await fetch(`${API_URL}/ingest`, { + method: "POST", + body: formData, + }); + if (!res.ok) throw new Error(res.statusText); + return res.json(); }, /** @@ -36,28 +34,24 @@ export const agentService = { async searchFMU(file, sensors) { const formData = new FormData(); formData.append("file", file); - - formData.append("sensors", JSON.stringify({ - pH: parseFloat(sensors.pH), - EC: parseFloat(sensors.EC), - temp: parseFloat(sensors.temp), - humidity: parseFloat(sensors.humidity), - crop: sensors.crop, - stage: sensors.stage, - crop_id: sensors.crop_id || "", - })); - - try { - const res = await fetch(`${API_URL}/search`, { - method: "POST", - body: formData, - }); - if (!res.ok) throw new Error(`Server Error: ${res.statusText}`); - return await res.json(); - } catch (error) { - console.error("Search Service Error:", error); - throw error; - } + formData.append( + "sensors", + JSON.stringify({ + pH: parseFloat(sensors.pH), + EC: parseFloat(sensors.EC), + temp: parseFloat(sensors.temp), + humidity: parseFloat(sensors.humidity), + crop: sensors.crop, + stage: sensors.stage, + crop_id: sensors.crop_id || "", + }), + ); + const res = await fetch(`${API_URL}/search`, { + method: "POST", + body: formData, + }); + if (!res.ok) throw new Error(res.statusText); + return res.json(); }, /** @@ -66,12 +60,11 @@ export const agentService = { async queryText(text) { const formData = new FormData(); formData.append("query", text); - const res = await fetch(`${API_URL}/query-text`, { - method: "POST", - body: formData + method: "POST", + body: formData, }); - return await res.json(); + return res.json(); }, /** @@ -80,11 +73,10 @@ export const agentService = { async queryAudio(audioBlob) { const formData = new FormData(); formData.append("file", audioBlob, "recording.webm"); - const res = await fetch(`${API_URL}/query-audio`, { - method: "POST", - body: formData + method: "POST", + body: formData, }); - return await res.json(); - } -}; -\ No newline at end of file + return res.json(); + }, +}; diff --git a/frontend/src/api/farmApi.jsx b/frontend/src/api/farmApi.jsx @@ -1,18 +1,15 @@ -// src/api/farmApi.js - -const API_BASE_URL = 'http://localhost:3001/api'; +const API_BASE_URL = "http://localhost:3001/api"; /** * Fetches the latest state of all unique crops for the Dashboard. */ export const fetchDashboardData = async () => { try { - const response = await fetch(`${API_BASE_URL}/dashboard`); - if (!response.ok) throw new Error('Network response was not ok'); - return await response.json(); - } catch (error) { - console.error("Failed to fetch dashboard data:", error); - return []; // Return empty array on failure + const res = await fetch(`${API_BASE_URL}/dashboard`); + if (!res.ok) throw new Error("Network error"); + return res.json(); + } catch { + return []; } }; @@ -21,11 +18,34 @@ export const fetchDashboardData = async () => { */ export const fetchCropDetails = async (cropId) => { try { - const response = await fetch(`${API_BASE_URL}/crop/${cropId}`); - if (!response.ok) throw new Error('Network response was not ok'); - return await response.json(); - } catch (error) { - console.error(`Failed to fetch details for ${cropId}:`, error); + const res = await fetch(`${API_BASE_URL}/crop/${cropId}`); + if (!res.ok) throw new Error("Network error"); + return res.json(); + } catch { return []; } -}; -\ No newline at end of file +}; + +/** + * Fetches full history for every crop present in the dashboard snapshot. + * Returns a flat array of all point objects sorted oldest → newest by timestamp. + */ +export const fetchAllCropHistories = async (dashboardItems) => { + if (!dashboardItems?.length) return []; + const cropIds = [ + ...new Set(dashboardItems.map((i) => i.payload?.crop_id).filter(Boolean)), + ]; + const results = await Promise.allSettled( + cropIds.map((id) => + fetch(`${API_BASE_URL}/crop/${id}`) + .then((r) => (r.ok ? r.json() : [])) + .catch(() => []), + ), + ); + const all = results.flatMap((r) => (r.status === "fulfilled" ? r.value : [])); + return all.sort((a, b) => { + const ta = new Date(a.payload?.timestamp || 0).getTime(); + const tb = new Date(b.payload?.timestamp || 0).getTime(); + return ta - tb; + }); +}; diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx @@ -0,0 +1,255 @@ +import React, { useState, useEffect } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { + Leaf, + LayoutGrid, + BarChart3, + Bell, + Settings, + Brain, + ChevronLeft, + 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; +} + +export default function Sidebar() { + const [collapsed, setCollapsed] = useState(false); + const [alertCount, setAlertCount] = useState(0); + const loc = useLocation(); + + useEffect(() => { + fetchDashboardData().then((data) => { + setAlertCount(countLiveAlerts(data)); + }); + }, []); + + const NAV = [ + { label: "Crops", icon: LayoutGrid, path: "/dashboard" }, + { label: "Analytics", icon: BarChart3, path: "/analytics" }, + { label: "Alerts", icon: Bell, path: "/alerts", badge: alertCount || null }, + { label: "Agent AI", icon: Brain, path: "/control" }, + { label: "Settings", icon: Settings, path: "#" }, + ]; + + return ( + <aside + className="flex flex-col border-r transition-all duration-300 relative" + style={{ + width: collapsed ? 64 : 220, + background: "var(--bg-2)", + borderColor: "var(--border)", + flexShrink: 0, + }} + > + {/* Logo */} + <div + className="flex items-center gap-3 px-4 py-5 border-b" + style={{ borderColor: "var(--border)" }} + > + <div + className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0" + style={{ background: "linear-gradient(135deg, #2d7a44, #4ade80)" }} + > + <Leaf size={16} fill="white" color="white" /> + </div> + {!collapsed && ( + <div className="overflow-hidden"> + <div className="font-bold text-sm" style={{ color: "var(--text)" }}> + Demeter + </div> + <div + className="text-[10px] font-mono" + style={{ color: "var(--text-3)" }} + > + AGRI·AI·v2 + </div> + </div> + )} + </div> + + {/* Status bar */} + {!collapsed && ( + <div + className="mx-3 mt-3 px-3 py-2 rounded-lg flex items-center gap-2" + style={{ + background: "rgba(74,222,128,0.08)", + border: "1px solid rgba(74,222,128,0.2)", + }} + > + <span + className="status-dot w-1.5 h-1.5 rounded-full flex-shrink-0" + style={{ background: "var(--green)" }} + /> + <span + className="text-[10px] font-mono" + style={{ color: "var(--green)" }} + > + SYSTEM ONLINE + </span> + </div> + )} + + {/* Nav */} + <nav className="flex-1 px-2 py-4 space-y-1"> + {NAV.map(({ label, icon: Icon, path, badge }) => { + const active = loc.pathname === path; + return ( + <Link + key={label} + to={path} + className="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all relative group" + style={{ + background: active ? "rgba(74,222,128,0.12)" : "transparent", + color: active ? "var(--green)" : "var(--text-2)", + border: active + ? "1px solid rgba(74,222,128,0.25)" + : "1px solid transparent", + }} + > + <Icon size={18} className="flex-shrink-0" /> + {!collapsed && ( + <span className="text-sm font-medium">{label}</span> + )} + {badge && !collapsed && ( + <span + className="ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded alert-pulse" + style={{ + background: "rgba(248,113,113,0.2)", + color: "var(--red)", + }} + > + {badge} + </span> + )} + {badge && collapsed && ( + <span + className="absolute top-1 right-1 w-2 h-2 rounded-full alert-pulse" + style={{ background: "var(--red)" }} + /> + )} + {collapsed && ( + <div + className="absolute left-full ml-2 px-2 py-1 rounded text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50 whitespace-nowrap" + style={{ + background: "var(--surface-2)", + border: "1px solid var(--border)", + color: "var(--text)", + }} + > + {label} + </div> + )} + </Link> + ); + })} + </nav> + + {/* System health */} + {!collapsed && ( + <div className="p-3 border-t" style={{ borderColor: "var(--border)" }}> + <div + className="px-3 py-2 rounded-lg" + style={{ background: "var(--surface)" }} + > + <div className="flex items-center justify-between mb-1"> + <span + className="text-[10px] font-mono" + style={{ color: "var(--text-3)" }} + > + ALERT STATUS + </span> + <Zap + size={10} + style={{ + color: alertCount > 0 ? "var(--amber)" : "var(--green)", + }} + /> + </div> + <div + className="h-1 rounded-full" + style={{ background: "var(--border)" }} + > + <div + className="h-full rounded-full" + style={{ + width: + alertCount > 0 + ? `${Math.min(alertCount * 20, 100)}%` + : "5%", + background: alertCount > 0 ? "var(--amber)" : "var(--green)", + }} + /> + </div> + <div + className="text-[10px] font-mono mt-1" + style={{ color: "var(--text-3)" }} + > + {alertCount > 0 + ? `${alertCount} crop(s) need attention` + : "All clear"} + </div> + </div> + </div> + )} + + {/* Collapse toggle */} + <button + onClick={() => setCollapsed(!collapsed)} + className="absolute -right-3 top-20 w-6 h-6 rounded-full flex items-center justify-center z-10 transition-colors" + style={{ + background: "var(--surface-2)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} + > + {collapsed ? <ChevronRight size={12} /> : <ChevronLeft size={12} />} + </button> + + {/* User */} + <div className="p-3 border-t" style={{ borderColor: "var(--border)" }}> + <div className="flex items-center gap-2"> + <div + className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0" + style={{ background: "var(--amber-dim)", color: "var(--amber)" }} + > + R + </div> + {!collapsed && ( + <div> + <div + className="text-xs font-semibold" + style={{ color: "var(--text)" }} + > + Rajesh Rai + </div> + <div className="text-[10px]" style={{ color: "var(--text-3)" }}> + Farm Owner + </div> + </div> + )} + </div> + </div> + </aside> + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css @@ -1,23 +1,294 @@ +@import url("https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Instrument+Serif:ital@0;1&display=swap"); + @tailwind base; @tailwind components; @tailwind utilities; :root { - --background: #ffffff; - --foreground: #171717; + --bg: #0c1a0e; + --bg-2: #111f13; + --bg-3: #162018; + --surface: #1a2b1c; + --surface-2: #1f3322; + --border: #2a3f2c; + --border-bright: #3d6040; + --text: #e8f0e9; + --text-2: #a8bfaa; + --text-3: #6a8a6d; + --green: #4ade80; + --green-dim: #2d7a44; + --amber: #f59e0b; + --amber-dim: #92400e; + --red: #f87171; + --red-dim: #7f1d1d; + --blue: #60a5fa; + --blue-dim: #1e3a5f; +} + +* { + box-sizing: border-box; } body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + background: var(--bg); + color: var(--text); + font-family: "Syne", sans-serif; -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: var(--bg-2); +} +::-webkit-scrollbar-thumb { + background: var(--border-bright); + border-radius: 3px; +} + +/* Utility classes */ +.font-mono { + font-family: "DM Mono", monospace; +} +.font-serif { + font-family: "Instrument Serif", serif; +} + +/* Noise texture overlay */ +.noise::after { + content: ""; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E"); + pointer-events: none; + z-index: 0; +} + +/* Glow effects */ +.glow-green { + box-shadow: + 0 0 20px rgba(74, 222, 128, 0.15), + 0 0 60px rgba(74, 222, 128, 0.05); +} +.glow-amber { + box-shadow: + 0 0 20px rgba(245, 158, 11, 0.2), + 0 0 60px rgba(245, 158, 11, 0.08); +} +.glow-red { + box-shadow: + 0 0 20px rgba(248, 113, 113, 0.2), + 0 0 60px rgba(248, 113, 113, 0.08); +} + +/* Scan line animation */ +@keyframes scanline { + 0% { + transform: translateY(-100%); + } + 100% { + transform: translateY(100vh); + } +} + +.scanline { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient( + 90deg, + transparent, + rgba(74, 222, 128, 0.1), + transparent + ); + animation: scanline 8s linear infinite; + pointer-events: none; + z-index: 9999; } -@layer utilities { - .animate-in { - animation: animate-in 0.5s ease-out; +/* Fade in animation */ +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(16px); } -} -\ No newline at end of file + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes pulse-border { + 0%, + 100% { + border-color: rgba(74, 222, 128, 0.3); + } + 50% { + border-color: rgba(74, 222, 128, 0.8); + } +} + +@keyframes ticker { + 0% { + transform: translateX(100%); + } + 100% { + transform: translateX(-100%); + } +} + +.animate-fade-up { + animation: fadeUp 0.5s ease forwards; +} +.animate-fade-in { + animation: fadeIn 0.3s ease forwards; +} + +/* Status dot pulse */ +@keyframes statusPulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(0.8); + } +} + +.status-dot { + animation: statusPulse 2s ease-in-out infinite; +} + +/* Card hover effect */ +.card-hover { + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + border-color 0.2s ease; +} +.card-hover:hover { + transform: translateY(-2px); + border-color: var(--border-bright); +} + +/* Progress bar animation */ +@keyframes progressFill { + from { + width: 0; + } +} + +.progress-fill { + animation: progressFill 1s ease forwards; +} + +/* Number counter */ +@keyframes countUp { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.count-up { + animation: countUp 0.6s ease forwards; +} + +/* Grid line background */ +.grid-bg { + background-image: + linear-gradient(rgba(74, 222, 128, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(74, 222, 128, 0.03) 1px, transparent 1px); + background-size: 40px 40px; +} + +/* Terminal cursor blink */ +@keyframes blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +.cursor-blink::after { + content: "|"; + animation: blink 1s step-end infinite; + color: var(--green); +} + +/* Shimmer loading */ +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.shimmer { + background: linear-gradient( + 90deg, + var(--surface) 25%, + var(--surface-2) 50%, + var(--surface) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + +/* Alert badge pulse */ +@keyframes alertPulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.4); + } + 70% { + box-shadow: 0 0 0 8px rgba(248, 113, 113, 0); + } +} + +.alert-pulse { + animation: alertPulse 2s ease infinite; +} + +/* Rotating ring */ +@keyframes spin-slow { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} +.spin-slow { + animation: spin-slow 20s linear infinite; +} +.spin-slow-reverse { + animation: spin-slow 15s linear infinite reverse; +} diff --git a/frontend/src/pages/AgentControl.jsx b/frontend/src/pages/AgentControl.jsx @@ -1,5 +1,4 @@ -import React, { useRef, useState } from "react"; -import { Link } from "react-router-dom"; +import { useRef, useState } from "react"; import { Upload, Save, @@ -7,10 +6,8 @@ import { Droplets, Thermometer, Wind, - Search, Sprout, Calendar, - ArrowLeft, Leaf, Database, Mic, @@ -20,9 +17,92 @@ import { FlaskConical, Waves, Brain, + ChevronDown, + Eye, } from "lucide-react"; import { agentService } from "../api/agentApi"; import { extractSensors, formatOutcome } from "../utils/dataUtils"; +import Sidebar from "../components/Sidebar"; + +const INPUT_FIELDS = [ + { + label: "pH Level", + name: "pH", + icon: Droplets, + color: "var(--green)", + type: "number", + }, + { + label: "EC (mS/cm)", + name: "EC", + icon: Activity, + color: "var(--amber)", + type: "number", + }, + { + label: "Temp (°C)", + name: "temp", + icon: Thermometer, + color: "var(--blue)", + type: "number", + }, + { + label: "Humidity (%)", + name: "humidity", + icon: Wind, + color: "#a78bfa", + type: "number", + }, + { + label: "Crop", + name: "crop", + icon: Sprout, + color: "var(--green)", + type: "select", + opts: ["Lettuce", "Tomato", "Cucumber", "Basil", "Spinach"], + }, + { + label: "Stage", + name: "stage", + icon: Calendar, + color: "var(--text-3)", + type: "select", + opts: ["Seedling", "Vegetative", "Flowering", "Fruiting"], + }, +]; + +const ACTION_MAP = { + acid_dosage_ml: { + label: "Acid Dosage", + icon: FlaskConical, + unit: "ml", + color: "var(--red)", + }, + base_dosage_ml: { + label: "Base Dosage", + icon: FlaskConical, + unit: "ml", + color: "#a78bfa", + }, + nutrient_dosage_ml: { + label: "Nutrients", + icon: Sprout, + unit: "ml", + color: "var(--green)", + }, + fan_speed_pct: { + label: "Fan Speed", + icon: Fan, + unit: "%", + color: "var(--blue)", + }, + water_refill_l: { + label: "Water Refill", + icon: Waves, + unit: "L", + color: "var(--blue)", + }, +}; export default function AgentControl() { const [file, setFile] = useState(null); @@ -41,9 +121,8 @@ export default function AgentControl() { const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); - // 🧠 State for the Supervisor's Output const [decision, setDecision] = useState(null); - const [strategy, setStrategy] = useState(""); + const [toast, setToast] = useState(null); const [sensors, setSensors] = useState({ pH: "6.0", @@ -54,54 +133,43 @@ export default function AgentControl() { stage: "Vegetative", }); - // --- Handlers --- - const handleFileChange = (e) => { - if (e.target.files && e.target.files[0]) { - const selected = e.target.files[0]; - setFile(selected); - setPreview(URL.createObjectURL(selected)); - setSearchResults([]); - setDecision(null); - setStrategy(""); - setExplanationText(""); - } + const showToast = (msg, type = "success") => { + setToast({ msg, type }); + setTimeout(() => setToast(null), 3000); }; - const handleInputChange = (e) => { - setSensors({ ...sensors, [e.target.name]: e.target.value }); + const handleFile = (e) => { + if (e.target.files?.[0]) { + setFile(e.target.files[0]); + setPreview(URL.createObjectURL(e.target.files[0])); + setDecision(null); + } }; const handleIngest = async () => { - if (!file) return alert("Please select an image first."); + if (!file) return showToast("Select an image first", "error"); setLoadingIngest(true); try { await agentService.uploadFMU(file, sensors); - alert("✅ FMU Created & Stored Successfully!"); - } catch (error) { - console.error(error); - alert("❌ Ingest Failed. Check console."); + showToast("Memory stored successfully"); + } catch { + showToast("Ingest failed", "error"); } finally { setLoadingIngest(false); } }; const handleSearch = async () => { - if (!file) return alert("Please select an image to search with."); + if (!file) return showToast("Select an image to analyze", "error"); setLoadingSearch(true); setDecision(null); - setStrategy(""); - try { - const response = await agentService.searchFMU(file, sensors); - - if (response.explanation) setExplanationText(response.explanation); - if (response.strategy) setStrategy(response.strategy); - if (response.agent_decision) setDecision(response.agent_decision); - - setSearchResults(response.search_results || []); - } catch (error) { - console.error(error); - alert("❌ Search Failed."); + const res = await agentService.searchFMU(file, sensors); + if (res.explanation) setExplanationText(res.explanation); + if (res.agent_decision) setDecision(res.agent_decision); + setSearchResults(res.search_results || []); + } catch { + showToast("Analysis failed", "error"); } finally { setLoadingSearch(false); } @@ -110,82 +178,23 @@ export default function AgentControl() { const handleTextQuery = async () => { if (!textQuery) return; setLoadingSearch(true); - setSearchResults([]); - try { const data = await agentService.queryText(textQuery); - if (data.results) { - // Map backend format to frontend expectation - const mappedResults = data.results.map((r) => ({ - id: r.id, - score: r.score || 1.0, - payload: r.payload, - })); - setSearchResults(mappedResults); - if (mappedResults.length === 0) alert("No records found."); - } - } catch (e) { - console.error(e); - alert("Text Query Failed"); + if (data.results) + setSearchResults( + data.results.map((r) => ({ + id: r.id, + score: r.score || 1, + payload: r.payload, + })), + ); + } catch { + showToast("Query failed", "error"); } finally { setLoadingSearch(false); } }; - // --- Helper to Map Decision Keys to UI --- - const getActionCardProps = (key, value) => { - switch (key) { - case "acid_dosage_ml": - return { - label: "Acid Dosage", - value: `${value} ml`, - icon: FlaskConical, - color: "text-rose-500", - bg: "bg-rose-50", - }; - case "base_dosage_ml": - return { - label: "Base Dosage", - value: `${value} ml`, - icon: FlaskConical, - color: "text-indigo-500", - bg: "bg-indigo-50", - }; - case "nutrient_dosage_ml": - return { - label: "Nutrient Mix", - value: `${value} ml`, - icon: Sprout, - color: "text-emerald-500", - bg: "bg-emerald-50", - }; - case "fan_speed_pct": - return { - label: "Fan Speed", - value: `${value}%`, - icon: Fan, - color: "text-cyan-500", - bg: "bg-cyan-50", - }; - case "water_refill_l": - return { - label: "Water Refill", - value: `${value} L`, - icon: Waves, - color: "text-blue-500", - bg: "bg-blue-50", - }; - default: - return { - label: key.replace(/_/g, " "), - value: value, - icon: Zap, - color: "text-gray-500", - bg: "bg-gray-50", - }; - } - }; - const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); @@ -195,15 +204,28 @@ export default function AgentControl() { if (e.data.size > 0) chunksRef.current.push(e.data); }; mediaRecorderRef.current.onstop = async () => { - const audioBlob = new Blob(chunksRef.current, { type: "audio/webm" }); - await handleAudioUpload(audioBlob); - stream.getTracks().forEach((track) => track.stop()); + const blob = new Blob(chunksRef.current, { type: "audio/webm" }); + setLoadingSearch(true); + try { + const data = await agentService.queryAudio(blob); + if (data.transcription) setTextQuery(data.transcription); + if (data.results) + setSearchResults( + data.results.map((r) => ({ + id: r.id, + score: r.score || 1, + payload: r.payload, + })), + ); + } finally { + setLoadingSearch(false); + } + stream.getTracks().forEach((t) => t.stop()); }; mediaRecorderRef.current.start(); setIsRecording(true); - } catch (err) { - console.error("Mic Error:", err); - alert("Microphone access denied."); + } catch { + showToast("Microphone access denied", "error"); } }; @@ -214,457 +236,487 @@ export default function AgentControl() { } }; - const handleAudioUpload = async (audioBlob) => { - setLoadingSearch(true); - setSearchResults([]); - try { - const data = await agentService.queryAudio(audioBlob); - if (data.transcription) setTextQuery(data.transcription); - if (data.results) { - const mappedResults = data.results.map((r) => ({ - id: r.id, - score: r.score || 1.0, - payload: r.payload, - })); - setSearchResults(mappedResults); - } - } catch (e) { - console.error(e); - alert("Audio Query Failed"); - } finally { - setLoadingSearch(false); - } - }; - return ( - <div className="min-h-screen bg-[#F4F9F6] font-sans text-gray-800 pb-20"> - {/* --- 1. NAVBAR --- */} - <nav className="border-b border-gray-200 bg-white sticky top-0 z-20 h-16 shadow-sm"> - <div className="max-w-7xl mx-auto px-6 h-full flex items-center justify-between"> - <Link - to="/" - className="flex items-center space-x-2 hover:opacity-80 transition" + <div + className="flex h-screen overflow-hidden" + style={{ background: "var(--bg)" }} + > + <Sidebar /> + + {/* Toast */} + {toast && ( + <div + className="fixed top-4 right-4 z-50 px-4 py-3 rounded-xl text-sm font-mono animate-fade-in" + style={{ + background: + toast.type === "error" + ? "rgba(248,113,113,0.15)" + : "rgba(74,222,128,0.15)", + border: `1px solid ${toast.type === "error" ? "rgba(248,113,113,0.4)" : "rgba(74,222,128,0.4)"}`, + color: toast.type === "error" ? "var(--red)" : "var(--green)", + }} + > + {toast.msg} + </div> + )} + + <main className="flex-1 flex flex-col overflow-hidden"> + {/* Header */} + <header + className="flex-shrink-0 px-6 py-4 border-b flex items-center gap-3" + style={{ borderColor: "var(--border)", background: "var(--bg-2)" }} + > + <div + className="w-8 h-8 rounded-lg flex items-center justify-center" + style={{ + background: "rgba(74,222,128,0.1)", + border: "1px solid rgba(74,222,128,0.2)", + }} > - <div className="bg-emerald-500 p-1.5 rounded-lg text-white"> - <Leaf size={20} fill="currentColor" /> - </div> - <span className="text-xl font-bold tracking-tight text-gray-900"> - Demeter - </span> - </Link> - <div className="flex items-center space-x-6 text-[10px] font-bold text-gray-500 uppercase tracking-widest"> - <Link - to="/dashboard" - className="hover:text-emerald-600 transition-colors" - > - Dashboard - </Link> + <Brain size={15} style={{ color: "var(--green)" }} /> </div> - </div> - </nav> - - <div className="max-w-7xl mx-auto px-6 mt-8"> - {/* Header Section */} - <div className="mb-8 flex items-center justify-between"> - <div className="flex items-center gap-4"> - <div className="bg-white p-3 rounded-xl border border-gray-200 shadow-sm"> - <Brain className="text-emerald-500 w-8 h-8" /> - </div> - <div> - <h1 className="text-3xl font-bold text-gray-900"> - Agent Control Center - </h1> - <p className="text-gray-500 mt-1"> - Ingest new crop memories or query the Supervisor Agent - </p> - </div> + <div> + <h1 + className="font-bold text-base" + style={{ color: "var(--text)" }} + > + Agent Control + </h1> + <p + className="text-[11px] font-mono" + style={{ color: "var(--text-3)" }} + > + Ingest memories · Query the Supervisor · Run analysis + </p> </div> - <Link - to="/" - className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-emerald-600 transition-colors bg-white px-4 py-2.5 rounded-lg border border-gray-200 shadow-sm hover:shadow-md" + </header> + + <div className="flex-1 overflow-y-auto p-6"> + {/* Search bar */} + <div + className="flex gap-2 mb-6 p-2 rounded-xl" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} > - <ArrowLeft size={18} /> Back Home - </Link> - </div> - - {/* --- MAIN GRID --- */} - <div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-12"> - {/* LEFT: Image Upload (Span 5) */} - <div className="lg:col-span-5 space-y-6"> - <div className="relative border-2 border-dashed border-gray-300 bg-white rounded-3xl h-[420px] flex flex-col items-center justify-center hover:border-emerald-500/50 hover:bg-emerald-50/30 transition-all group overflow-hidden shadow-sm hover:shadow-md"> - <input - type="file" - onChange={handleFileChange} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" - /> - {preview ? ( - <img - src={preview} - alt="Preview" - className="h-full w-full object-cover" - /> - ) : ( - <div className="text-center p-6 space-y-4"> - <div className="w-20 h-20 bg-emerald-50 rounded-full flex items-center justify-center mx-auto group-hover:scale-110 transition-transform duration-300"> - <Upload className="w-8 h-8 text-emerald-500" /> - </div> - <div> - <p className="text-gray-900 font-bold text-lg"> - Upload Crop Scan - </p> - <p className="text-gray-400 text-sm"> - Drag & drop or click to browse - </p> - </div> - </div> - )} - </div> + <button + onClick={isRecording ? stopRecording : startRecording} + className="p-2 rounded-lg transition-all" + style={{ + background: isRecording + ? "rgba(248,113,113,0.15)" + : "var(--bg-3)", + border: `1px solid ${isRecording ? "rgba(248,113,113,0.4)" : "var(--border)"}`, + color: isRecording ? "var(--red)" : "var(--text-3)", + }} + > + {isRecording ? <Square size={14} /> : <Mic size={14} />} + </button> + <input + value={textQuery} + onChange={(e) => setTextQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleTextQuery()} + placeholder="Ask Demeter: 'Show all failed Lettuce crops'…" + className="flex-1 bg-transparent border-none outline-none text-sm font-mono px-2" + style={{ color: "var(--text)", caretColor: "var(--green)" }} + /> + <button + onClick={handleTextQuery} + className="px-4 py-2 rounded-lg text-sm font-semibold transition-all" + style={{ background: "var(--green)", color: "#0c1a0e" }} + > + Ask + </button> </div> - {/* RIGHT: Controls (Span 7) */} - <div className="lg:col-span-7 space-y-6"> - {/* Search Bar */} - <div className="bg-white p-2 rounded-2xl border border-gray-200 flex gap-2 shadow-sm focus-within:shadow-md transition-shadow"> - <button - onClick={isRecording ? stopRecording : startRecording} - className={`p-3 rounded-xl transition-all flex items-center justify-center ${ - isRecording - ? "bg-red-50 text-red-500 animate-pulse border border-red-100" - : "bg-gray-50 text-gray-400 hover:text-gray-600 hover:bg-gray-100" - }`} - title="Voice Search" + {/* Main grid */} + <div className="grid grid-cols-1 lg:grid-cols-5 gap-6"> + {/* Image upload */} + <div className="lg:col-span-2"> + <label + className="relative block rounded-2xl overflow-hidden cursor-pointer" + style={{ + height: 320, + background: "var(--surface)", + border: "2px dashed var(--border)", + }} > - {isRecording ? ( - <Square className="w-5 h-5" /> + <input + type="file" + onChange={handleFile} + className="absolute inset-0 opacity-0 cursor-pointer z-10" + /> + {preview ? ( + <img + src={preview} + alt="preview" + className="w-full h-full object-cover" + /> ) : ( - <Mic className="w-5 h-5" /> - )} - </button> - <input - type="text" - value={textQuery} - onChange={(e) => setTextQuery(e.target.value)} - placeholder="Ask Demeter: 'Show me all failed Lettuce crops'..." - className="flex-1 bg-transparent border-none outline-none text-gray-700 placeholder-gray-400 px-2 font-medium" - /> - <button - onClick={handleTextQuery} - className="bg-emerald-500 hover:bg-emerald-600 text-white px-6 py-2 rounded-xl font-bold transition-all shadow-lg shadow-emerald-500/20" - > - Ask Agent - </button> - </div> - - {/* Sensor Inputs Panel */} - <div className="bg-white border border-gray-200 rounded-3xl p-8 space-y-6 shadow-sm"> - <div className="flex items-center justify-between"> - <h3 className="text-gray-900 font-bold flex items-center gap-2 text-lg"> - <Activity className="w-5 h-5 text-emerald-500" /> Manual - Parameters - </h3> - </div> - - <div className="grid grid-cols-2 gap-5"> - {[ - { - label: "pH Level", - name: "pH", - icon: Droplets, - color: "text-emerald-600", - bg: "bg-emerald-50", - type: "number", - }, - { - label: "EC (mS/cm)", - name: "EC", - icon: Activity, - color: "text-yellow-600", - bg: "bg-yellow-50", - type: "number", - }, - { - label: "Temp (°C)", - name: "temp", - icon: Thermometer, - color: "text-red-600", - bg: "bg-red-50", - type: "number", - }, - { - label: "Humidity (%)", - name: "humidity", - icon: Wind, - color: "text-blue-600", - bg: "bg-blue-50", - type: "number", - }, - { - label: "Crop", - name: "crop", - icon: Sprout, - color: "text-green-600", - bg: "bg-green-50", - type: "select", - options: [ - "Lettuce", - "Tomato", - "Cucumber", - "Basil", - "Spinach", - ], - }, - { - label: "Stage", - name: "stage", - icon: Calendar, - color: "text-purple-600", - bg: "bg-purple-50", - type: "select", - options: [ - "Seedling", - "Vegetative", - "Flowering", - "Fruiting", - ], - }, - ].map((field) => ( - <div key={field.name} className="space-y-2 group"> - <label - className={`text-[11px] font-bold uppercase tracking-wider ${field.color} ml-1`} + <div className="flex flex-col items-center justify-center h-full gap-3 p-6"> + <div + className="w-14 h-14 rounded-2xl flex items-center justify-center" + style={{ + background: "var(--bg-3)", + border: "1px solid var(--border)", + }} > - {field.label} - </label> - <div className="relative"> + <Upload size={22} style={{ color: "var(--text-3)" }} /> + </div> + <div className="text-center"> <div - className={`absolute left-3 top-2.5 w-8 h-8 rounded-lg ${field.bg} flex items-center justify-center z-10`} + className="font-semibold text-sm" + style={{ color: "var(--text-2)" }} > - <field.icon className={`w-4 h-4 ${field.color}`} /> + Drop crop image + </div> + <div + className="text-xs mt-1" + style={{ color: "var(--text-3)" }} + > + PNG, JPG up to 10MB </div> - {field.type === "select" ? ( - <select - name={field.name} - value={sensors[field.name]} - onChange={handleInputChange} - className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 pl-14 pr-4 focus:ring-2 focus:ring-emerald-500 focus:bg-white outline-none transition-all text-gray-800 font-bold appearance-none cursor-pointer" - > - {field.options.map((opt) => ( - <option key={opt} value={opt}> - {opt} - </option> - ))} - </select> - ) : ( - <input - name={field.name} - value={sensors[field.name]} - onChange={handleInputChange} - type="number" - step="0.1" - className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 pl-14 pr-4 focus:ring-2 focus:ring-emerald-500 focus:bg-white outline-none transition-all text-gray-800 font-bold" - /> - )} </div> </div> - ))} - </div> + )} + {/* Overlay gradient */} + {preview && ( + <div + className="absolute inset-0" + style={{ + background: + "linear-gradient(to top, rgba(12,26,14,0.6) 0%, transparent 60%)", + }} + /> + )} + </label> - {/* Action Buttons */} - <div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-100"> + {/* Action buttons */} + <div className="grid grid-cols-2 gap-3 mt-3"> <button onClick={handleIngest} disabled={loadingIngest || loadingSearch} - className="py-3.5 bg-gray-100 hover:bg-emerald-50 text-gray-600 hover:text-emerald-700 font-bold rounded-xl transition-all disabled:opacity-50 flex items-center justify-center space-x-2 group" + className="py-3 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-2)", + }} > {loadingIngest ? ( - <Activity className="animate-spin w-5 h-5" /> + <Activity size={14} className="animate-spin" /> ) : ( <> - <Save className="w-5 h-5 text-gray-400 group-hover:text-emerald-500 transition-colors" />{" "} - <span>Store Memory</span> + <Save size={14} /> Store </> )} </button> - <button onClick={handleSearch} disabled={loadingIngest || loadingSearch} - className="py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl transition-all shadow-lg shadow-blue-500/20 disabled:opacity-50 flex items-center justify-center space-x-2 group" + className="py-3 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all" + style={{ background: "var(--green)", color: "#0c1a0e" }} > {loadingSearch ? ( - <Activity className="animate-spin w-5 h-5" /> + <Activity size={14} className="animate-spin" /> ) : ( <> - <Search className="w-5 h-5" /> <span>Reason & Solve</span> + <Brain size={14} /> Analyze </> )} </button> </div> </div> - </div> - </div> - {/* --- OUTPUT SECTION --- */} - - {/* 1. Decision & Action Grid (Supervisor) */} - {decision && ( - <div className="mb-12 animate-in fade-in slide-in-from-bottom-4 duration-700"> - <div className="bg-white border border-emerald-100 rounded-3xl overflow-hidden shadow-xl shadow-emerald-500/10"> - {/* Header with Strategy */} - <div className="p-6 border-b border-gray-100 bg-emerald-50/50 flex flex-col md:flex-row justify-between items-start md:items-center gap-4"> - <div> - <h3 className="text-gray-900 font-bold text-lg flex items-center gap-2"> - <div className="bg-emerald-500 text-white p-1.5 rounded-lg"> - <Brain size={18} /> + {/* Sensor inputs */} + <div + className="lg:col-span-3 rounded-2xl p-5" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + className="text-[10px] font-mono mb-4" + style={{ color: "var(--text-3)" }} + > + // SENSOR PARAMETERS + </div> + <div className="grid grid-cols-2 gap-4"> + {INPUT_FIELDS.map( + ({ label, name, icon: Icon, color, type, opts }) => ( + <div key={name}> + <label + className="text-[10px] font-mono mb-1 block" + style={{ color }} + > + {label.toUpperCase()} + </label> + <div className="relative"> + <div className="absolute left-3 top-1/2 -translate-y-1/2 z-10"> + <Icon size={13} style={{ color }} /> + </div> + {type === "select" ? ( + <div className="relative"> + <select + value={sensors[name]} + onChange={(e) => + setSensors({ + ...sensors, + [name]: e.target.value, + }) + } + className="w-full appearance-none pl-8 pr-8 py-2.5 rounded-lg text-sm font-mono outline-none" + style={{ + background: "var(--bg-3)", + border: "1px solid var(--border)", + color: "var(--text)", + }} + > + {opts.map((o) => ( + <option key={o} value={o}> + {o} + </option> + ))} + </select> + <ChevronDown + size={11} + className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" + style={{ color: "var(--text-3)" }} + /> + </div> + ) : ( + <input + value={sensors[name]} + onChange={(e) => + setSensors({ ...sensors, [name]: e.target.value }) + } + type="number" + step="0.1" + className="w-full pl-8 pr-3 py-2.5 rounded-lg text-sm font-mono outline-none" + style={{ + background: "var(--bg-3)", + border: "1px solid var(--border)", + color: "var(--text)", + }} + /> + )} + </div> </div> + ), + )} + </div> + </div> + </div> + + {/* Decision output */} + {decision && ( + <div + className="mt-6 rounded-2xl overflow-hidden animate-fade-up" + style={{ + background: "var(--surface)", + border: "1px solid rgba(74,222,128,0.3)", + }} + > + <div + className="p-4 border-b flex items-center justify-between" + style={{ + borderColor: "var(--border)", + background: "rgba(74,222,128,0.05)", + }} + > + <div className="flex items-center gap-2"> + <Brain size={16} style={{ color: "var(--green)" }} /> + <span + className="font-semibold text-sm" + style={{ color: "var(--text)" }} + > Supervisor Command - </h3> - <p className="text-xs font-mono text-emerald-600 mt-1 uppercase tracking-wide"> - Active Strategy:{" "} - <span className="font-bold"> - {strategy || "ANALYZING..."} - </span> - </p> + </span> </div> - <button onClick={() => setShowExplanation(!showExplanation)} - className="text-xs font-semibold text-emerald-600 hover:text-emerald-700 bg-white border border-emerald-200 px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1 shadow-sm" + className="flex items-center gap-1.5 text-[11px] font-mono px-2.5 py-1.5 rounded-lg transition-colors" + style={{ + background: "var(--surface-2)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} > - <Search size={12} /> View Logic Trace + <Eye size={11} /> {showExplanation ? "Hide" : "View"} logic </button> </div> - {/* ACTION GRID */} - <div className="p-8"> - <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4"> - {Object.entries(decision).map(([key, value]) => { - const props = getActionCardProps(key, value); - return ( + <div className="p-5 grid grid-cols-3 md:grid-cols-5 gap-3"> + {Object.entries(decision).map(([key, value]) => { + const meta = ACTION_MAP[key] || { + label: key, + icon: Zap, + unit: "", + color: "var(--text-3)", + }; + const Icon = meta.icon; + return ( + <div + key={key} + className="rounded-xl p-4 text-center" + style={{ + background: "var(--bg-3)", + border: "1px solid var(--border)", + }} + > <div - key={key} - className="bg-gray-50 border border-gray-100 rounded-2xl p-4 flex flex-col items-center justify-center text-center hover:border-emerald-200 hover:shadow-md transition-all" + className="w-8 h-8 rounded-lg flex items-center justify-center mx-auto mb-2" + style={{ background: `${meta.color}15` }} > - <div - className={`w-10 h-10 rounded-full ${props.bg} flex items-center justify-center mb-3`} - > - <props.icon className={`w-5 h-5 ${props.color}`} /> - </div> - <div className="text-2xl font-bold text-gray-800 font-mono mb-1"> - {props.value} - </div> - <div className="text-[10px] uppercase font-bold text-gray-400 tracking-wider"> - {props.label} - </div> + <Icon size={14} style={{ color: meta.color }} /> </div> - ); - })} - </div> + <div + className="font-bold font-mono text-xl" + style={{ color: meta.color }} + > + {value} + </div> + <div + className="text-[9px] font-mono mt-0.5" + style={{ color: "var(--text-3)" }} + > + {meta.unit} + </div> + <div + className="text-[10px] mt-1" + style={{ color: "var(--text-3)" }} + > + {meta.label} + </div> + </div> + ); + })} </div> - {/* Explainer Drawer */} - {showExplanation && ( - <div className="bg-gray-50 p-6 border-t border-gray-200 animate-in slide-in-from-top-2"> - <h4 className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-3"> - Supervisor Thought Process - </h4> - <div className="text-gray-600 text-sm whitespace-pre-wrap font-mono leading-relaxed bg-white p-4 rounded-xl border border-gray-200 shadow-sm"> - {explanationText || "Generating logic trace..."} + {showExplanation && explanationText && ( + <div + className="border-t p-5" + style={{ + borderColor: "var(--border)", + background: "var(--bg-3)", + }} + > + <div + className="text-[10px] font-mono mb-2" + style={{ color: "var(--text-3)" }} + > + // SUPERVISOR REASONING </div> + <pre + className="text-xs font-mono leading-relaxed whitespace-pre-wrap" + style={{ color: "var(--text-2)" }} + > + {explanationText} + </pre> </div> )} </div> - </div> - )} - - {/* 2. Search Results Grid (Fixed for Sensors) */} - {searchResults.length > 0 && ( - <div className="animate-in fade-in slide-in-from-bottom-8 duration-700"> - <div className="flex items-center justify-between mb-8"> - <h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2"> - <Database className="w-6 h-6 text-blue-500" /> - Retrieved Memory Matches - </h2> - <span className="text-[11px] font-bold text-blue-700 bg-blue-50 px-3 py-1.5 rounded-full border border-blue-100"> - {searchResults.length} SIMILAR CASES FOUND - </span> - </div> - - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> - {searchResults.map((res) => { - const sensors = extractSensors(res.payload); - - return ( - <div - key={res.id} - className="bg-white border border-gray-200 rounded-2xl hover:border-blue-300 hover:shadow-xl hover:shadow-blue-500/10 transition-all group relative overflow-hidden flex flex-col" - > - {/* Confidence Badge */} - <div className="absolute top-0 right-0 bg-blue-600 text-white text-[10px] font-bold px-3 py-1 rounded-bl-xl shadow-lg z-10"> - {(res.score * 100).toFixed(1)}% MATCH - </div> - - {/* Card Header */} - <div className="p-5 border-b border-gray-100 bg-gray-50/50"> - <h3 className="text-lg font-bold text-gray-900 flex items-center gap-2"> - <Sprout className="w-5 h-5 text-emerald-500" /> - {res.payload.crop} - </h3> - <span className="text-xs text-gray-400 font-bold uppercase tracking-wider mt-1 block"> - {res.payload.stage} Phase - </span> - </div> - - {/* Card Body */} - <div className="p-5 space-y-4 flex-1"> - <div className="flex items-center justify-between text-sm text-gray-500"> - <div className="flex items-center gap-2 font-medium"> - <Calendar className="w-4 h-4 text-gray-400" /> Date + )} + + {/* Search results */} + {searchResults.length > 0 && ( + <div className="mt-6"> + <div className="flex items-center gap-2 mb-4"> + <Database size={14} style={{ color: "var(--text-3)" }} /> + <span + className="text-[11px] font-mono" + style={{ color: "var(--text-3)" }} + > + MEMORY MATCHES · {searchResults.length} FOUND + </span> + </div> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + {searchResults.map((res) => { + const s = extractSensors(res.payload); + return ( + <div + key={res.id} + className="rounded-xl p-4 card-hover" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div className="flex items-center justify-between mb-3"> + <div className="flex items-center gap-2"> + <Leaf size={13} style={{ color: "var(--green)" }} /> + <span + className="font-semibold text-sm" + style={{ color: "var(--text)" }} + > + {res.payload.crop || "Unknown"} + </span> </div> - <span className="font-mono text-gray-700 bg-gray-100 px-2 py-0.5 rounded text-xs"> - {res.payload.timestamp - ? new Date( - res.payload.timestamp, - ).toLocaleDateString() - : "N/A"} + <span + className="text-[10px] font-mono px-2 py-0.5 rounded" + style={{ + background: "rgba(74,222,128,0.1)", + color: "var(--green)", + }} + > + {((res.score || 1) * 100).toFixed(0)}% </span> </div> - - <div className="grid grid-cols-2 gap-2 mt-2"> - <div className="text-center p-2 rounded-lg bg-emerald-50 border border-emerald-100"> - <div className="text-[10px] text-emerald-600 font-bold uppercase"> - pH Level + <div className="grid grid-cols-2 gap-2"> + <div + className="rounded-lg p-2 text-center" + style={{ background: "var(--bg-3)" }} + > + <div + className="text-[9px] font-mono" + style={{ color: "var(--text-3)" }} + > + pH </div> - <div className="text-emerald-800 font-mono font-bold text-lg"> - {sensors.ph} + <div + className="font-mono font-bold text-sm" + style={{ color: "var(--green)" }} + > + {s.ph} </div> </div> - <div className="text-center p-2 rounded-lg bg-yellow-50 border border-yellow-100"> - <div className="text-[10px] text-yellow-600 font-bold uppercase"> - EC Level + <div + className="rounded-lg p-2 text-center" + style={{ background: "var(--bg-3)" }} + > + <div + className="text-[9px] font-mono" + style={{ color: "var(--text-3)" }} + > + EC </div> - <div className="text-yellow-800 font-mono font-bold text-lg"> - {sensors.ec} + <div + className="font-mono font-bold text-sm" + style={{ color: "var(--amber)" }} + > + {s.ec} </div> </div> </div> - - {/* Optional Outcome Section */} {res.payload.outcome && ( - <div className="mt-2 text-xs bg-gray-50 p-2 rounded border border-gray-100 text-gray-600 line-clamp-3"> - <span className="font-bold text-gray-400 uppercase text-[10px] block mb-1"> - Outcome Note: - </span> - {formatOutcome(res.payload.outcome)} + <div + className="mt-2 text-[11px]" + style={{ color: "var(--text-3)" }} + > + {formatOutcome(res.payload.outcome)?.substring(0, 80)} + … </div> )} </div> - </div> - ); - })} + ); + })} + </div> </div> - </div> - )} - </div> + )} + </div> + </main> </div> ); } diff --git a/frontend/src/pages/Alerts.jsx b/frontend/src/pages/Alerts.jsx @@ -0,0 +1,670 @@ +import { useState, useEffect, useMemo } from "react"; +import { + AlertTriangle, + CheckCircle2, + Info, + Zap, + X, + Bell, + BellOff, + Clock, + RefreshCw, +} from "lucide-react"; +import { fetchDashboardData, fetchAllCropHistories } from "../api/farmApi"; +import { extractSensors, formatOutcome } from "../utils/dataUtils"; +import Sidebar from "../components/Sidebar"; + +function timeAgo(isoString) { + if (!isoString) return "unknown"; + const diff = Date.now() - new Date(isoString).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins} min ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs} hr ago`; + return `${Math.floor(hrs / 24)} days ago`; +} + +/** + * Derive real alerts from stored Qdrant points. + * Rules: + * critical — pH < 4.5 | pH > 7.5 | EC > 3.5 | temp < 10 | temp > 35 + * outcome contains 'fail' / 'critical' / 'disease' / 'error' + * action_taken contains 'DISEASE' / 'PEST' / 'FUNGAL' + * warning — pH 4.5–5.4 | pH 6.6–7.5 | EC 2.5–3.5 | temp 10–17 | temp 30–35 + * outcome contains 'deteriorat' / 'negative' / 'attention' + * action_taken contains 'FLUSH' / 'PRUNE' / 'BOOST' + * info — completed cycles (sequence_number present), strategy changes + */ +function generateAlerts(points) { + const alerts = []; + let id = 1; + + for (const p of points) { + const payload = p.payload || {}; + const s = extractSensors(payload); + const ph = parseFloat(s.ph) || 0; + const ec = parseFloat(s.ec) || 0; + const temp = parseFloat(s.temp) || 0; + const ts = payload.timestamp; + const cropId = payload.crop_id || "UNKNOWN"; + const cropName = payload.crop || "Crop"; + const action = (payload.action_taken || "").toUpperCase(); + const outcome = (payload.outcome || "").toLowerCase(); + const strategy = (payload.strategic_intent || "").toUpperCase(); + const seq = payload.sequence_number; + + // Critical: pH + if (ph > 0 && ph < 4.5) { + alerts.push({ + id: id++, + severity: "critical", + title: "pH critically low", + desc: `${cropName} (${cropId}): pH at ${ph} — well below safe range. Immediate base dosing required.`, + time: timeAgo(ts), + ts, + agent: "WATER", + crop: cropName, + ack: false, + }); + } else if (ph > 7.5) { + alerts.push({ + id: id++, + severity: "critical", + title: "pH critically high", + desc: `${cropName} (${cropId}): pH at ${ph} — far above optimal. Acid dosing required immediately.`, + time: timeAgo(ts), + ts, + agent: "WATER", + crop: cropName, + ack: false, + }); + } + + // Critical: EC + if (ec > 3.5) { + alerts.push({ + id: id++, + severity: "critical", + title: "EC dangerously high", + desc: `${cropName} (${cropId}): EC at ${ec} dS/m — severe nutrient burn risk. Flush immediately.`, + time: timeAgo(ts), + ts, + agent: "WATER", + crop: cropName, + ack: false, + }); + } + + // Critical: Temp + if (temp > 0 && temp < 10) { + alerts.push({ + id: id++, + severity: "critical", + title: "Temperature too cold", + desc: `${cropName} (${cropId}): Air temp at ${temp}°C — plant stress and root damage risk.`, + time: timeAgo(ts), + ts, + agent: "ATMOSPHERIC", + crop: cropName, + ack: false, + }); + } else if (temp > 35) { + alerts.push({ + id: id++, + severity: "critical", + title: "Temperature too hot", + desc: `${cropName} (${cropId}): Air temp at ${temp}°C — heat stress and root rot risk.`, + time: timeAgo(ts), + ts, + agent: "ATMOSPHERIC", + crop: cropName, + ack: false, + }); + } + + // Critical: disease keywords in outcome/action + if ( + /disease|fungal|pest|mildew|blight|mite|rot/.test(outcome) || + /DISEASE|FUNGAL|PEST/.test(action) + ) { + alerts.push({ + id: id++, + severity: "critical", + title: "Disease or pest detected", + desc: `${cropName} (${cropId}): Visual anomaly in stored record. Outcome: "${formatOutcome(payload.outcome)}"`, + time: timeAgo(ts), + ts, + agent: "DOCTOR", + crop: cropName, + ack: false, + }); + } + + // Critical: fail/critical outcome + if (/fail|critical|error/.test(outcome)) { + alerts.push({ + id: id++, + severity: "critical", + title: "Cycle failure recorded", + desc: `${cropName} (${cropId}): Sequence #${seq} outcome: "${formatOutcome(payload.outcome)}"`, + time: timeAgo(ts), + ts, + agent: "JUDGE", + crop: cropName, + ack: false, + }); + } + + // Warning: pH mild drift + if (ph >= 4.5 && ph < 5.5) { + alerts.push({ + id: id++, + severity: "warning", + title: "pH below optimal range", + desc: `${cropName} (${cropId}): pH at ${ph}. Target 5.5–6.5 — gentle base adjustment recommended.`, + time: timeAgo(ts), + ts, + agent: "WATER", + crop: cropName, + ack: false, + }); + } else if (ph > 6.6 && ph <= 7.5) { + alerts.push({ + id: id++, + severity: "warning", + title: "pH above optimal range", + desc: `${cropName} (${cropId}): pH at ${ph}. Target 5.5–6.5 — gentle acid adjustment recommended.`, + time: timeAgo(ts), + ts, + agent: "WATER", + crop: cropName, + ack: false, + }); + } + + // Warning: EC elevated + if (ec >= 2.5 && ec <= 3.5) { + alerts.push({ + id: id++, + severity: "warning", + title: "EC approaching high limit", + desc: `${cropName} (${cropId}): EC at ${ec} dS/m — nutrient burn risk increasing.`, + time: timeAgo(ts), + ts, + agent: "SUPERVISOR", + crop: cropName, + ack: false, + }); + } + + // Warning: temp mild + if (temp >= 10 && temp < 17) { + alerts.push({ + id: id++, + severity: "warning", + title: "Temperature on the low side", + desc: `${cropName} (${cropId}): ${temp}°C — slow growth and reduced nutrient uptake expected.`, + time: timeAgo(ts), + ts, + agent: "ATMOSPHERIC", + crop: cropName, + ack: false, + }); + } else if (temp >= 30 && temp <= 35) { + alerts.push({ + id: id++, + severity: "warning", + title: "Temperature elevated", + desc: `${cropName} (${cropId}): ${temp}°C — heat stress likely. Increase ventilation.`, + time: timeAgo(ts), + ts, + agent: "ATMOSPHERIC", + crop: cropName, + ack: false, + }); + } + + // Warning: deterioration outcome + if (/deteriorat|negative|attention|decline/.test(outcome)) { + alerts.push({ + id: id++, + severity: "warning", + title: "Condition deteriorating", + desc: `${cropName} (${cropId}): Sequence #${seq} — "${formatOutcome(payload.outcome)}"`, + time: timeAgo(ts), + ts, + agent: "JUDGE", + crop: cropName, + ack: false, + }); + } + + // Info: completed cycle + if (seq && !/fail|critical|error|deteriorat|negative/.test(outcome)) { + alerts.push({ + 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(), + time: timeAgo(ts), + ts, + agent: strategy ? "SUPERVISOR" : "JUDGE", + crop: cropName, + ack: true, + }); + } + } + + // De-duplicate: keep at most 3 alerts of same severity+title+crop + 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) { + 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]) + return sevOrder[a.severity] - sevOrder[b.severity]; + return new Date(b.ts || 0) - new Date(a.ts || 0); + }); + + return deduped; +} + +// STYLES + +const SEV = { + critical: { + icon: AlertTriangle, + bg: "rgba(248,113,113,0.1)", + border: "rgba(248,113,113,0.3)", + text: "var(--red)", + label: "CRITICAL", + }, + warning: { + icon: Zap, + bg: "rgba(245,158,11,0.1)", + border: "rgba(245,158,11,0.25)", + text: "var(--amber)", + label: "WARNING", + }, + info: { + icon: Info, + bg: "rgba(96,165,250,0.1)", + border: "rgba(96,165,250,0.25)", + text: "var(--blue)", + label: "INFO", + }, +}; + +const AGENT_COLORS = { + WATER: "var(--blue)", + ATMOSPHERIC: "#a78bfa", + SUPERVISOR: "var(--green)", + JUDGE: "var(--amber)", + DOCTOR: "var(--red)", + HISTORIAN: "var(--text-3)", +}; + +function AlertCard({ alert, onAck, onDismiss }) { + const s = SEV[alert.severity]; + const Icon = s.icon; + + return ( + <div + className="rounded-xl p-4 transition-all card-hover" + style={{ + background: alert.ack ? "var(--surface)" : s.bg, + border: `1px solid ${alert.ack ? "var(--border)" : s.border}`, + opacity: alert.ack ? 0.6 : 1, + }} + > + <div className="flex items-start gap-3"> + <div + className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5" + style={{ background: s.bg, border: `1px solid ${s.border}` }} + > + <Icon size={15} style={{ color: s.text }} /> + </div> + + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2 flex-wrap"> + <span + className="font-semibold text-sm" + style={{ color: alert.ack ? "var(--text-2)" : "var(--text)" }} + > + {alert.title} + </span> + <span + className="text-[9px] font-mono px-1.5 py-0.5 rounded" + style={{ + background: s.bg, + color: s.text, + border: `1px solid ${s.border}`, + }} + > + {s.label} + </span> + <span + className="text-[10px] font-mono px-1.5 py-0.5 rounded" + style={{ + background: "rgba(0,0,0,0.3)", + color: AGENT_COLORS[alert.agent] || "var(--text-3)", + }} + > + {alert.agent} + </span> + </div> + <p + className="text-xs mt-1 leading-relaxed" + style={{ color: "var(--text-3)" }} + > + {alert.desc} + </p> + <div className="flex items-center gap-3 mt-2"> + <div + className="flex items-center gap-1 text-[10px] font-mono" + style={{ color: "var(--text-3)" }} + > + <Clock size={9} /> {alert.time} + </div> + <div + className="text-[10px] font-mono" + style={{ color: "var(--text-3)" }} + > + Crop: {alert.crop} + </div> + </div> + </div> + + <div className="flex items-center gap-1 flex-shrink-0"> + {!alert.ack && ( + <button + onClick={() => onAck(alert.id)} + title="Acknowledge" + className="p-1.5 rounded-lg transition-colors" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} + > + <CheckCircle2 size={13} /> + </button> + )} + <button + onClick={() => onDismiss(alert.id)} + title="Dismiss" + className="p-1.5 rounded-lg transition-colors" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} + > + <X size={13} /> + </button> + </div> + </div> + </div> + ); +} + +// MAIN + +export default function Alerts() { + 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(); + }, []); + + const ack = (id) => + setAlerts((a) => a.map((al) => (al.id === id ? { ...al, ack: true } : al))); + const dismiss = (id) => setAlerts((a) => a.filter((al) => al.id !== id)); + const ackAll = () => setAlerts((a) => a.map((al) => ({ ...al, ack: true }))); + + const counts = useMemo( + () => ({ + critical: alerts.filter((a) => a.severity === "critical" && !a.ack) + .length, + warning: alerts.filter((a) => a.severity === "warning" && !a.ack).length, + info: alerts.filter((a) => a.severity === "info" && !a.ack).length, + total: alerts.filter((a) => !a.ack).length, + }), + [alerts], + ); + + const filtered = useMemo( + () => + alerts.filter((a) => { + if (!showAcked && a.ack) return false; + if (filter !== "all" && a.severity !== filter) return false; + return true; + }), + [alerts, filter, showAcked], + ); + + return ( + <div + className="flex h-screen overflow-hidden" + style={{ background: "var(--bg)" }} + > + <Sidebar /> + <main className="flex-1 flex flex-col overflow-hidden"> + {/* 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)" }} + > + <div> + <h1 className="font-bold text-lg" style={{ color: "var(--text)" }}> + Alerts + </h1> + <p className="text-xs font-mono" style={{ color: "var(--text-3)" }}> + {loading + ? "Analyzing sensor history…" + : `${counts.total} unacknowledged · ${alerts.length} total`} + </p> + </div> + <div className="ml-auto flex items-center gap-2"> + <button + onClick={load} + className="p-2 rounded-lg transition-colors" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} + title="Reload" + > + <RefreshCw size={14} className={loading ? "animate-spin" : ""} /> + </button> + <button + onClick={() => setShowAcked(!showAcked)} + className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-mono transition-all" + style={{ + background: showAcked + ? "rgba(74,222,128,0.1)" + : "var(--surface)", + border: `1px solid ${showAcked ? "rgba(74,222,128,0.3)" : "var(--border)"}`, + color: showAcked ? "var(--green)" : "var(--text-3)", + }} + > + {showAcked ? <Bell size={12} /> : <BellOff size={12} />} + {showAcked ? "All" : "Unacked only"} + </button> + <button + onClick={ackAll} + className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-mono" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} + > + <CheckCircle2 size={12} /> Ack all + </button> + </div> + </header> + + {/* Filter bar */} + <div + className="flex-shrink-0 px-6 py-3 border-b flex items-center gap-3 overflow-x-auto" + style={{ borderColor: "var(--border)", background: "var(--bg-3)" }} + > + {[ + { key: "all", label: "All", count: alerts.length, color: null }, + { + key: "critical", + label: "Critical", + count: counts.critical, + color: "var(--red)", + }, + { + key: "warning", + label: "Warning", + count: counts.warning, + color: "var(--amber)", + }, + { + key: "info", + label: "Info", + count: counts.info, + color: "var(--blue)", + }, + ].map(({ key, label, count, color }) => ( + <button + key={key} + onClick={() => setFilter(key)} + className="flex items-center gap-2 px-3 py-1.5 rounded-full text-[11px] font-mono flex-shrink-0 transition-all" + style={{ + background: filter === key ? "var(--surface-2)" : "transparent", + border: `1px solid ${filter === key ? "var(--border-bright)" : "transparent"}`, + color: + filter === key ? color || "var(--text)" : "var(--text-3)", + }} + > + {count > 0 && ( + <span + className="w-4 h-4 rounded-full flex items-center justify-center text-[9px]" + style={{ + background: color ? `${color}30` : "var(--border)", + color: color || "var(--text-3)", + }} + > + {count} + </span> + )} + {label} + </button> + ))} + </div> + + {/* Alert list */} + <div className="flex-1 overflow-y-auto p-6"> + {loading ? ( + <div className="space-y-3 max-w-2xl mx-auto"> + {[1, 2, 3].map((i) => ( + <div + key={i} + className="h-20 rounded-xl shimmer" + style={{ border: "1px solid var(--border)" }} + /> + ))} + </div> + ) : filtered.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-full gap-4"> + <div + className="w-16 h-16 rounded-2xl flex items-center justify-center" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <CheckCircle2 size={28} style={{ color: "var(--green)" }} /> + </div> + <div style={{ color: "var(--text-2)" }}> + {alerts.length === 0 + ? "No data loaded — connect your farm and run some cycles" + : "All clear for the selected filter"} + </div> + </div> + ) : ( + <div className="space-y-3 max-w-2xl mx-auto"> + {/* Unacked */} + {filtered.filter((a) => !a.ack).length > 0 && ( + <div> + <div + className="text-[10px] font-mono mb-3" + style={{ color: "var(--text-3)" }} + > + UNACKNOWLEDGED · {filtered.filter((a) => !a.ack).length} + </div> + <div className="space-y-2"> + {filtered + .filter((a) => !a.ack) + .map((a) => ( + <AlertCard + key={a.id} + alert={a} + onAck={ack} + onDismiss={dismiss} + /> + ))} + </div> + </div> + )} + + {/* Acked */} + {showAcked && filtered.filter((a) => a.ack).length > 0 && ( + <div className="mt-6"> + <div + className="text-[10px] font-mono mb-3" + style={{ color: "var(--text-3)" }} + > + ACKNOWLEDGED · {filtered.filter((a) => a.ack).length} + </div> + <div className="space-y-2"> + {filtered + .filter((a) => a.ack) + .map((a) => ( + <AlertCard + key={a.id} + alert={a} + onAck={ack} + onDismiss={dismiss} + /> + ))} + </div> + </div> + )} + </div> + )} + </div> + </main> + </div> + ); +} diff --git a/frontend/src/pages/Analytics.jsx b/frontend/src/pages/Analytics.jsx @@ -0,0 +1,1023 @@ +import { useState, useEffect, useMemo } from "react"; +import { + AreaChart, + Area, + LineChart, + Line, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + RadarChart, + Radar, + PolarGrid, + PolarAngleAxis, +} from "recharts"; +import { fetchDashboardData, fetchAllCropHistories } from "../api/farmApi"; +import { extractSensors } from "../utils/dataUtils"; +import { TrendingUp, TrendingDown, Minus, Download } from "lucide-react"; +import Sidebar from "../components/Sidebar"; + +// HELPERS + +function avg(arr) { + if (!arr.length) return 0; + return arr.reduce((s, v) => s + v, 0) / arr.length; +} + +function safePct(current, previous) { + if (!previous) return 0; + return parseFloat((((current - previous) / previous) * 100).toFixed(1)); +} + +// Group history points into buckets. +// range = '24h' → group by hour (last 24 entries) +// range = '7d' → group by day (last 7 days) +// range = '30d' → group by day (last 30 days) +function bucketHistory(points, range) { + if (!points.length) return []; + + const now = Date.now(); + const MS = { + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, + }; + const cutoff = now - (MS[range] || MS["24h"]); + + const filtered = points.filter((p) => { + const t = new Date(p.payload?.timestamp || 0).getTime(); + return t >= cutoff; + }); + + if (!filtered.length) return []; + + // Build buckets + const bucketSize = + range === "24h" + ? 60 * 60 * 1000 // 1 hour + : 24 * 60 * 60 * 1000; // 1 day + + const buckets = new Map(); + for (const p of filtered) { + const t = new Date(p.payload?.timestamp || 0).getTime(); + const key = Math.floor(t / bucketSize) * bucketSize; + if (!buckets.has(key)) buckets.set(key, []); + buckets.get(key).push(p); + } + + return Array.from(buckets.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([ts, pts]) => { + const date = new Date(ts); + const label = + range === "24h" + ? date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + : date.toLocaleDateString([], { month: "short", day: "numeric" }); + + const sensors = pts.map((p) => extractSensors(p.payload)); + return { + label, + ph: parseFloat( + avg(sensors.map((s) => parseFloat(s.ph) || 0)).toFixed(2), + ), + ec: parseFloat( + avg(sensors.map((s) => parseFloat(s.ec) || 0)).toFixed(2), + ), + temp: parseFloat( + avg(sensors.map((s) => parseFloat(s.temp) || 0)).toFixed(1), + ), + humidity: parseFloat( + avg(sensors.map((s) => parseFloat(s.humidity) || 0)).toFixed(1), + ), + count: pts.length, + }; + }); +} + +// Build per-day crop count for "sequences per day" bar chart +function dailyCropActivity(points) { + const map = new Map(); + for (const p of points) { + const ts = p.payload?.timestamp; + if (!ts) continue; + const day = new Date(ts).toLocaleDateString([], { + month: "short", + day: "numeric", + }); + map.set(day, (map.get(day) || 0) + 1); + } + const last7 = Array.from(map.entries()).slice(-7); + const maxVal = Math.max(...last7.map((e) => e[1]), 1); + return last7.map(([d, count]) => ({ + d, + count, + target: Math.ceil(maxVal * 1.2), + })); +} + +// Build radar: how close each param is to ideal range +function buildRadar(points) { + if (!points.length) return []; + const sensors = points.map((p) => extractSensors(p.payload)); + + const check = (vals, lo, hi) => { + const inRange = vals.filter((v) => v >= lo && v <= hi).length; + return Math.round((inRange / vals.length) * 100); + }; + + return [ + { + metric: "pH", + value: check( + sensors.map((s) => parseFloat(s.ph)), + 5.5, + 6.5, + ), + }, + { + metric: "EC", + value: check( + sensors.map((s) => parseFloat(s.ec)), + 0.8, + 2.5, + ), + }, + { + metric: "Temp", + value: check( + sensors.map((s) => parseFloat(s.temp)), + 18, + 28, + ), + }, + { + metric: "Humidity", + value: check( + sensors.map((s) => parseFloat(s.humidity)), + 40, + 80, + ), + }, + ]; +} + +// Derive which agents appear in action_taken fields and how many times +function buildAgentStats(points) { + const AGENTS = ["SUPERVISOR", "WATER", "ATMOSPHERIC", "JUDGE", "DOCTOR"]; + const counts = Object.fromEntries(AGENTS.map((a) => [a, 0])); + + for (const p of points) { + const action = + (p.payload?.action_taken || "") + + " " + + (p.payload?.strategic_intent || ""); + for (const agent of AGENTS) { + if (action.toUpperCase().includes(agent)) counts[agent]++; + } + // every stored point = at least one supervisor decision + counts["SUPERVISOR"]++; + } + + const total = Math.max(points.length, 1); + return AGENTS.map((name) => ({ + name, + decisions: counts[name], + // 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 / + total) * + 100, + ) + : 0, + })).filter((a) => a.decisions > 0); +} + +// COMPONENTS + +const CustomTooltip = ({ active, payload, label }) => { + if (!active || !payload?.length) return null; + return ( + <div + className="px-3 py-2 rounded-lg text-xs font-mono" + style={{ + background: "var(--surface-2)", + border: "1px solid var(--border)", + color: "var(--text)", + }} + > + <div style={{ color: "var(--text-3)", marginBottom: 4 }}>{label}</div> + {payload.map((p) => ( + <div key={p.dataKey} style={{ color: p.color }}> + {p.name}: {p.value} + </div> + ))} + </div> + ); +}; + +function MetricCard({ label, value, unit, change, color, loading }) { + const up = change > 0, + flat = change === 0; + return ( + <div + className="rounded-xl p-5 card-hover" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + className="text-[11px] font-mono mb-3" + style={{ color: "var(--text-3)" }} + > + {label} + </div> + {loading ? ( + <div className="h-8 w-24 rounded shimmer" /> + ) : ( + <div + className="text-3xl font-bold font-mono" + style={{ color: color || "var(--text)" }} + > + {value} + <span + className="text-base font-normal ml-1" + style={{ color: "var(--text-3)" }} + > + {unit} + </span> + </div> + )} + <div className="flex items-center gap-1 mt-2 text-[11px] font-mono"> + {flat ? ( + <Minus size={11} style={{ color: "var(--text-3)" }} /> + ) : up ? ( + <TrendingUp size={11} style={{ color: "var(--green)" }} /> + ) : ( + <TrendingDown size={11} style={{ color: "var(--red)" }} /> + )} + <span + style={{ + color: flat ? "var(--text-3)" : up ? "var(--green)" : "var(--red)", + }} + > + {Math.abs(change)}% vs prior period + </span> + </div> + </div> + ); +} + +function SectionTitle({ children, sub }) { + return ( + <div className="mb-4"> + <div className="text-[10px] font-mono" style={{ color: "var(--text-3)" }}> + // {sub} + </div> + <h2 + className="font-bold text-base mt-0.5" + style={{ color: "var(--text)" }} + > + {children} + </h2> + </div> + ); +} + +function EmptyChart({ height = 180, message = "No data yet" }) { + return ( + <div + className="flex items-center justify-center rounded-lg" + style={{ + height, + background: "var(--bg-3)", + border: "1px dashed var(--border)", + }} + > + <span className="text-xs font-mono" style={{ color: "var(--text-3)" }}> + {message} + </span> + </div> + ); +} + +// MAIN + +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 buckets = useMemo( + () => bucketHistory(allPoints, range), + [allPoints, range], + ); + + // Compute averages from latest snapshot + const latestSensors = useMemo(() => { + if (!dashboard.length) return { ph: 0, ec: 0, temp: 0 }; + const sensors = dashboard.map((d) => extractSensors(d.payload)); + return { + ph: parseFloat(avg(sensors.map((s) => parseFloat(s.ph) || 0)).toFixed(2)), + ec: parseFloat(avg(sensors.map((s) => parseFloat(s.ec) || 0)).toFixed(2)), + temp: parseFloat( + avg(sensors.map((s) => parseFloat(s.temp) || 0)).toFixed(1), + ), + }; + }, [dashboard]); + + // Compare first half vs second half of history to get "change" + const prevSensors = useMemo(() => { + if (allPoints.length < 2) return latestSensors; + const half = Math.floor(allPoints.length / 2); + const older = allPoints + .slice(0, half) + .map((p) => extractSensors(p.payload)); + return { + ph: parseFloat(avg(older.map((s) => parseFloat(s.ph) || 0)).toFixed(2)), + ec: parseFloat(avg(older.map((s) => parseFloat(s.ec) || 0)).toFixed(2)), + temp: parseFloat( + avg(older.map((s) => parseFloat(s.temp) || 0)).toFixed(1), + ), + }; + }, [allPoints, latestSensors]); + + const activityData = useMemo(() => dailyCropActivity(allPoints), [allPoints]); + const radarData = useMemo(() => buildRadar(allPoints), [allPoints]); + const agentStats = useMemo(() => buildAgentStats(allPoints), [allPoints]); + + // Export CSV of bucketed data + const handleExport = () => { + if (!buckets.length) return; + const header = "time,ph,ec,temp,humidity,entries"; + const rows = buckets.map( + (b) => `${b.label},${b.ph},${b.ec},${b.temp},${b.humidity},${b.count}`, + ); + const blob = new Blob([[header, ...rows].join("\n")], { type: "text/csv" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `demeter-analytics-${range}.csv`; + a.click(); + }; + + return ( + <div + className="flex h-screen overflow-hidden" + style={{ background: "var(--bg)" }} + > + <Sidebar /> + <main className="flex-1 flex flex-col overflow-hidden"> + {/* ── 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)" }} + > + <div> + <h1 className="font-bold text-lg" style={{ color: "var(--text)" }}> + Analytics + </h1> + <p className="text-xs font-mono" style={{ color: "var(--text-3)" }}> + {loading + ? "Loading…" + : `${allPoints.length} data points across ${dashboard.length} crops`} + </p> + </div> + <div className="ml-auto flex items-center gap-2"> + {["24h", "7d", "30d"].map((r) => ( + <button + key={r} + onClick={() => setRange(r)} + className="px-3 py-1.5 rounded-lg text-[11px] font-mono transition-all" + style={{ + background: + range === r ? "rgba(74,222,128,0.12)" : "var(--surface)", + border: `1px solid ${range === r ? "rgba(74,222,128,0.3)" : "var(--border)"}`, + color: range === r ? "var(--green)" : "var(--text-3)", + }} + > + {r} + </button> + ))} + <button + onClick={handleExport} + className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-mono" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} + > + <Download size={12} /> Export CSV + </button> + </div> + </header> + + <div className="flex-1 overflow-y-auto p-6 space-y-8"> + {/* ── Metric cards ── */} + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <MetricCard + loading={loading} + label="AVG pH" + value={latestSensors.ph} + unit="" + change={safePct(latestSensors.ph, prevSensors.ph)} + color="var(--green)" + /> + <MetricCard + loading={loading} + label="AVG EC" + value={latestSensors.ec} + unit="dS/m" + change={safePct(latestSensors.ec, prevSensors.ec)} + color="var(--amber)" + /> + <MetricCard + loading={loading} + label="AVG TEMP" + value={latestSensors.temp} + unit="°C" + change={safePct(latestSensors.temp, prevSensors.temp)} + color="var(--blue)" + /> + <MetricCard + loading={loading} + label="TOTAL SEQUENCES" + value={allPoints.length} + unit="" + change={safePct( + allPoints.length, + Math.max(allPoints.length - dashboard.length, 1), + )} + color="var(--text)" + /> + </div> + + {/* ── pH + EC ── */} + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> + <div + className="rounded-xl p-5" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <SectionTitle sub={`${range.toUpperCase()} TRACE`}> + pH Over Time + </SectionTitle> + {buckets.length < 2 ? ( + <EmptyChart + height={180} + message="Not enough data for this range" + /> + ) : ( + <ResponsiveContainer width="100%" height={180}> + <AreaChart data={buckets}> + <defs> + <linearGradient id="phGrad" x1="0" y1="0" x2="0" y2="1"> + <stop + offset="0%" + stopColor="#4ade80" + stopOpacity={0.3} + /> + <stop + offset="100%" + stopColor="#4ade80" + stopOpacity={0} + /> + </linearGradient> + </defs> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} + /> + <XAxis + dataKey="label" + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + interval="preserveStartEnd" + /> + <YAxis + domain={["auto", "auto"]} + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <Tooltip content={<CustomTooltip />} /> + <Area + type="monotone" + dataKey="ph" + stroke="var(--green)" + fill="url(#phGrad)" + strokeWidth={2} + dot={false} + name="pH" + /> + </AreaChart> + </ResponsiveContainer> + )} + </div> + + <div + className="rounded-xl p-5" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <SectionTitle sub={`${range.toUpperCase()} TRACE`}> + EC Concentration + </SectionTitle> + {buckets.length < 2 ? ( + <EmptyChart + height={180} + message="Not enough data for this range" + /> + ) : ( + <ResponsiveContainer width="100%" height={180}> + <AreaChart data={buckets}> + <defs> + <linearGradient id="ecGrad" x1="0" y1="0" x2="0" y2="1"> + <stop + offset="0%" + stopColor="#f59e0b" + stopOpacity={0.25} + /> + <stop + offset="100%" + stopColor="#f59e0b" + stopOpacity={0} + /> + </linearGradient> + </defs> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} + /> + <XAxis + dataKey="label" + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + interval="preserveStartEnd" + /> + <YAxis + domain={["auto", "auto"]} + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <Tooltip content={<CustomTooltip />} /> + <Area + type="monotone" + dataKey="ec" + stroke="var(--amber)" + fill="url(#ecGrad)" + strokeWidth={2} + dot={false} + name="EC" + /> + </AreaChart> + </ResponsiveContainer> + )} + </div> + </div> + + {/* ── Temp + Humidity ── */} + <div + className="rounded-xl p-5" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <SectionTitle sub={`${range.toUpperCase()} TRACE`}> + Temperature & Humidity + </SectionTitle> + {buckets.length < 2 ? ( + <EmptyChart + height={180} + message="Not enough data for this range" + /> + ) : ( + <ResponsiveContainer width="100%" height={180}> + <LineChart data={buckets}> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} + /> + <XAxis + dataKey="label" + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + interval="preserveStartEnd" + /> + <YAxis + yAxisId="left" + domain={["auto", "auto"]} + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <YAxis + yAxisId="right" + orientation="right" + domain={["auto", "auto"]} + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <Tooltip content={<CustomTooltip />} /> + <Line + yAxisId="left" + type="monotone" + dataKey="temp" + stroke="#60a5fa" + strokeWidth={2} + dot={false} + name="Temp °C" + /> + <Line + yAxisId="right" + type="monotone" + dataKey="humidity" + stroke="#a78bfa" + strokeWidth={2} + dot={false} + name="Humidity %" + /> + </LineChart> + </ResponsiveContainer> + )} + </div> + + {/* ── Activity bar + Radar ── */} + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> + <div + className="rounded-xl p-5" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <SectionTitle sub="DAILY ACTIVITY"> + Sequences Logged per Day + </SectionTitle> + {activityData.length < 2 ? ( + <EmptyChart height={180} message="Need 2+ days of data" /> + ) : ( + <ResponsiveContainer width="100%" height={180}> + <BarChart data={activityData} barGap={4}> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} + /> + <XAxis + dataKey="d" + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <YAxis + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + allowDecimals={false} + /> + <Tooltip content={<CustomTooltip />} /> + <Bar + dataKey="count" + fill="#2d7a44" + radius={[4, 4, 0, 0]} + name="Sequences" + /> + </BarChart> + </ResponsiveContainer> + )} + </div> + + <div + className="rounded-xl p-5" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <SectionTitle sub="PARAMETER HEALTH"> + In-Range Score (%) + </SectionTitle> + {radarData.length < 2 ? ( + <EmptyChart height={180} message="Not enough data points" /> + ) : ( + <ResponsiveContainer width="100%" height={180}> + <RadarChart + data={radarData} + cx="50%" + cy="50%" + outerRadius="65%" + > + <PolarGrid stroke="var(--border)" /> + <PolarAngleAxis + dataKey="metric" + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + /> + <Radar + dataKey="value" + stroke="var(--green)" + fill="rgba(74,222,128,0.15)" + strokeWidth={2} + name="In-range %" + /> + <Tooltip content={<CustomTooltip />} /> + </RadarChart> + </ResponsiveContainer> + )} + </div> + </div> + + {/* ── Crop breakdown table ── */} + <div + className="rounded-xl overflow-hidden" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + className="p-5 border-b" + style={{ borderColor: "var(--border)" }} + > + <SectionTitle sub="PER CROP">Latest Sensor Summary</SectionTitle> + </div> + {loading ? ( + <div className="p-8 flex justify-center"> + <span + className="text-xs font-mono" + style={{ color: "var(--text-3)" }} + > + Loading… + </span> + </div> + ) : dashboard.length === 0 ? ( + <div + className="p-8 text-center text-xs font-mono" + style={{ color: "var(--text-3)" }} + > + No crops in database + </div> + ) : ( + <table className="w-full text-sm"> + <thead> + <tr style={{ borderBottom: "1px solid var(--border)" }}> + {[ + "Crop ID", + "Type", + "Stage", + "pH", + "EC", + "Temp", + "Sequences", + ].map((h) => ( + <th + key={h} + className="px-5 py-3 text-left text-[10px] font-mono" + style={{ color: "var(--text-3)" }} + > + {h} + </th> + ))} + </tr> + </thead> + <tbody> + {dashboard.map((item, i) => { + const p = item.payload || {}; + const s = extractSensors(p); + return ( + <tr + key={i} + style={{ borderBottom: "1px solid var(--border)" }} + > + <td + className="px-5 py-3 font-mono text-xs" + style={{ color: "var(--text)" }} + > + {p.crop_id || "—"} + </td> + <td + className="px-5 py-3 font-mono text-xs" + style={{ color: "var(--text-2)" }} + > + {p.crop || "—"} + </td> + <td + className="px-5 py-3 font-mono text-xs" + style={{ color: "var(--text-2)" }} + > + {p.stage || "—"} + </td> + <td + className="px-5 py-3 font-mono text-xs" + style={{ color: "var(--green)" }} + > + {s.ph} + </td> + <td + className="px-5 py-3 font-mono text-xs" + style={{ color: "var(--amber)" }} + > + {s.ec} + </td> + <td + className="px-5 py-3 font-mono text-xs" + style={{ color: "var(--blue)" }} + > + {s.temp}°C + </td> + <td + className="px-5 py-3 font-mono text-xs" + style={{ color: "var(--text-2)" }} + > + {p.sequence_number || 1} + </td> + </tr> + ); + })} + </tbody> + </table> + )} + </div> + + {/* Agent activity derived from action_taken fields */} + {agentStats.length > 0 && ( + <div + className="rounded-xl overflow-hidden" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + className="p-5 border-b" + style={{ borderColor: "var(--border)" }} + > + <SectionTitle sub="DERIVED FROM STORED ACTIONS"> + Agent Activity + </SectionTitle> + </div> + <table className="w-full text-sm"> + <thead> + <tr style={{ borderBottom: "1px solid var(--border)" }}> + {[ + "Agent", + "Appearances in Log", + "Success Rate", + "Status", + ].map((h) => ( + <th + key={h} + className="px-5 py-3 text-left text-[10px] font-mono" + style={{ color: "var(--text-3)" }} + > + {h} + </th> + ))} + </tr> + </thead> + <tbody> + {agentStats.map(({ name, decisions, accuracy }) => ( + <tr + key={name} + style={{ borderBottom: "1px solid var(--border)" }} + > + <td + className="px-5 py-3 font-mono text-xs" + style={{ color: "var(--text)" }} + > + {name} + </td> + <td + className="px-5 py-3 font-mono text-xs" + style={{ color: "var(--text-2)" }} + > + {decisions} + </td> + <td className="px-5 py-3"> + <div className="flex items-center gap-2"> + <div + className="h-1.5 w-24 rounded-full" + style={{ background: "var(--border)" }} + > + <div + className="h-full rounded-full" + style={{ + width: `${accuracy}%`, + background: + accuracy > 80 + ? "var(--green)" + : accuracy > 50 + ? "var(--amber)" + : "var(--red)", + }} + /> + </div> + <span + className="font-mono text-xs" + style={{ color: "var(--text-2)" }} + > + {accuracy}% + </span> + </div> + </td> + <td className="px-5 py-3"> + <span + className="text-[10px] font-mono px-2 py-0.5 rounded-full" + style={{ + background: "rgba(74,222,128,0.1)", + color: "var(--green)", + border: "1px solid rgba(74,222,128,0.2)", + }} + > + ONLINE + </span> + </td> + </tr> + ))} + </tbody> + </table> + </div> + )} + </div> + </main> + </div> + ); +} diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx @@ -5,10 +5,14 @@ import { ArrowLeft, Thermometer, Droplet, - FlaskConical, - Sparkles, + Wind, + Zap, + Activity, + Clock, } from "lucide-react"; import { + AreaChart, + Area, LineChart, Line, XAxis, @@ -23,333 +27,580 @@ import { formatNumber, formatOutcome, } from "../utils/dataUtils"; +import Sidebar from "../components/Sidebar"; -const CropDetails = () => { +const CustomTooltip = ({ active, payload, label }) => { + if (!active || !payload?.length) return null; + return ( + <div + className="px-3 py-2 rounded-lg text-xs font-mono" + style={{ + background: "var(--surface-2)", + border: "1px solid var(--border)", + color: "var(--text)", + }} + > + <div style={{ color: "var(--text-3)", marginBottom: 4 }}>{label}</div> + {payload.map((p) => ( + <div key={p.dataKey} style={{ color: p.color }}> + {p.name}: {p.value} + </div> + ))} + </div> + ); +}; + +function StatBox({ icon: Icon, label, value, color, unit }) { + return ( + <div + className="rounded-xl p-4" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div className="flex items-center gap-2 mb-3"> + <div + className="w-7 h-7 rounded-lg flex items-center justify-center" + style={{ background: `${color}15`, border: `1px solid ${color}30` }} + > + <Icon size={13} style={{ color }} /> + </div> + <span + className="text-[10px] font-mono uppercase" + style={{ color: "var(--text-3)" }} + > + {label} + </span> + </div> + <div className="text-2xl font-bold font-mono" style={{ color }}> + {value} + <span + className="text-sm font-normal ml-0.5" + style={{ color: "var(--text-3)" }} + > + {unit} + </span> + </div> + </div> + ); +} + +export default function CropDetails() { const { cropId } = useParams(); const navigate = useNavigate(); - const [history, setHistory] = useState([]); const [latest, setLatest] = useState(null); const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState("overview"); useEffect(() => { - const getData = async () => { - try { - const data = await fetchCropDetails(cropId); - - if (data && Array.isArray(data) && data.length > 0) { - // Sort by sequence number - const sorted = [...data].sort( - (a, b) => - (a.payload?.sequence_number || 0) - - (b.payload?.sequence_number || 0), - ); - - // Process history with safety checks - const processedHistory = sorted.map((item) => { - const safePayload = item.payload || {}; - const sensors = extractSensors(safePayload); - return { - ...item, - cleanSensors: sensors, - parsedAction: parsePythonString(safePayload.action_taken), - }; - }); - - setHistory(processedHistory); - setLatest(processedHistory[processedHistory.length - 1]); - } else { - setHistory([]); - setLatest(null); - } - } catch (err) { - console.error("Error processing crop details:", err); - } finally { - setLoading(false); + fetchCropDetails(cropId).then((data) => { + if (data?.length) { + const sorted = [...data].sort( + (a, b) => + (a.payload?.sequence_number || 0) - + (b.payload?.sequence_number || 0), + ); + const processed = sorted.map((item) => { + const p = item.payload || {}; + const sensors = extractSensors(p); + return { + ...item, + cleanSensors: sensors, + parsedAction: parsePythonString(p.action_taken), + }; + }); + setHistory(processed); + setLatest(processed[processed.length - 1]); } - }; - getData(); + setLoading(false); + }); }, [cropId]); if (loading) return ( - <div className="h-screen flex items-center justify-center text-gray-500"> - Loading Crop Data... + <div className="flex h-screen" style={{ background: "var(--bg)" }}> + <Sidebar /> + <div className="flex-1 flex items-center justify-center"> + <div className="text-xs font-mono" style={{ color: "var(--text-3)" }}> + Loading crop data… + </div> + </div> </div> ); + if (!latest) return ( - <div className="h-screen flex items-center justify-center text-gray-500"> - Crop data not found. + <div className="flex h-screen" style={{ background: "var(--bg)" }}> + <Sidebar /> + <div className="flex-1 flex items-center justify-center"> + <div style={{ color: "var(--text-3)" }}>Crop not found</div> + </div> </div> ); - const latestPayload = latest.payload || {}; - // Safety: Ensure latestSensors is never undefined - const latestSensors = latest.cleanSensors || { - temp: 0, - ph: 0, - humidity: 0, - }; + const p = latest.payload || {}; + const sensors = latest.cleanSensors || {}; - // --- CHART DATA (With Safety Checks) --- const chartData = history.map((h) => ({ - time: h.payload?.timestamp + t: h.payload?.timestamp ? new Date(h.payload.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }) - : "--:--", + : "--", temp: formatNumber(h.cleanSensors?.temp), ph: formatNumber(h.cleanSensors?.ph), + ec: formatNumber(h.cleanSensors?.ec), + humidity: formatNumber(h.cleanSensors?.humidity), })); - // --- VITALS DATA --- - const vitals = [ - { - label: "Air Temp", - value: `${formatNumber(latestSensors.temp)}°C`, - status: "Optimal", - icon: <Thermometer size={18} className="text-orange-500" />, - color: "bg-orange-100", - }, - { - label: "Water pH", - value: formatNumber(latestSensors.ph) || "N/A", - status: "Stable", - icon: <FlaskConical size={18} className="text-purple-500" />, - color: "bg-purple-100", - }, - { - label: "Humidity", - value: `${formatNumber(latestSensors.humidity)}%`, - status: "Optimal", - icon: <Droplet size={18} className="text-blue-500" />, - color: "bg-blue-100", - }, - ]; + const TABS = ["overview", "sensors", "log"]; return ( - <div className="min-h-screen bg-[#F3F4F6] font-sans text-gray-800 flex flex-col"> - {/* HEADER */} - <header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center gap-4 sticky top-0 z-20"> - <button - onClick={() => navigate("/dashboard")} - className="p-2 hover:bg-gray-100 rounded-full transition" + <div + className="flex h-screen overflow-hidden" + style={{ background: "var(--bg)" }} + > + <Sidebar /> + <main className="flex-1 flex flex-col overflow-hidden"> + {/* 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)" }} > - <ArrowLeft size={20} /> - </button> - <div> - <div className="text-xs text-gray-500">Back to Dashboard</div> - <h1 className="font-bold text-xl text-gray-900"> - {latestPayload.crop || "Unknown Crop"}{" "} - <span className="text-gray-400"> - #{latestPayload.sequence_number || 0} - </span> - </h1> - </div> - <div className="ml-auto flex items-center gap-2 bg-emerald-50 text-emerald-700 px-3 py-1 rounded-full text-xs font-semibold"> - <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>{" "} - System Online - </div> - </header> + <button + onClick={() => navigate("/dashboard")} + className="p-2 rounded-lg transition-colors" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} + > + <ArrowLeft size={15} /> + </button> + <div> + <h1 + className="font-bold text-base" + style={{ color: "var(--text)" }} + > + {p.crop || "Unknown"}{" "} + <span style={{ color: "var(--text-3)" }}> + #{p.sequence_number || 0} + </span> + </h1> + <p + className="text-[11px] font-mono" + style={{ color: "var(--text-3)" }} + > + {cropId} · {p.stage} + </p> + </div> - {/* MAIN CONTENT */} - <main className="flex-1 max-w-7xl mx-auto w-full p-6 space-y-6"> - {/* TOP ROW: Vitals */} - <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> - <div className="bg-white rounded-2xl p-3 shadow-sm border border-gray-100 flex flex-col items-center justify-center text-center"> - <div className="relative w-full h-32 rounded-lg overflow-hidden bg-gray-100 mb-2"> - <img - src="https://images.unsplash.com/photo-1622206151226-18ca2c9ab4a1?q=80&w=2000" - className="w-full h-full object-cover" - alt="crop" - /> - </div> - <div className="text-xs uppercase text-gray-400 font-bold"> - Current Stage - </div> - <div className="text-lg font-bold text-emerald-600"> - {latestPayload.stage || "Unknown"} - </div> + {/* Status */} + <div + className="flex items-center gap-1.5 ml-4 px-3 py-1.5 rounded-full text-[11px] font-mono" + style={{ + background: "rgba(74,222,128,0.1)", + border: "1px solid rgba(74,222,128,0.25)", + color: "var(--green)", + }} + > + <span + className="status-dot w-1.5 h-1.5 rounded-full" + style={{ background: "var(--green)" }} + /> + LIVE + </div> + + {/* Tabs */} + <div className="ml-auto flex items-center gap-1"> + {TABS.map((tab) => ( + <button + key={tab} + onClick={() => setActiveTab(tab)} + className="px-3 py-1.5 rounded-lg text-[11px] font-mono capitalize transition-all" + style={{ + background: + activeTab === tab ? "var(--surface-2)" : "transparent", + color: activeTab === tab ? "var(--text)" : "var(--text-3)", + border: `1px solid ${activeTab === tab ? "var(--border-bright)" : "transparent"}`, + }} + > + {tab} + </button> + ))} </div> + </header> - <div className="lg:col-span-2 bg-white rounded-2xl p-5 shadow-sm border border-gray-100 grid grid-cols-1 md:grid-cols-3 gap-4"> - {vitals.map((v, i) => ( + <div className="flex-1 overflow-y-auto p-6 space-y-6"> + {activeTab === "overview" && ( + <> + {/* Stat grid */} + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <StatBox + icon={Thermometer} + label="Temperature" + value={formatNumber(sensors.temp)} + unit="°C" + color="var(--blue)" + /> + <StatBox + icon={Droplet} + label="pH Level" + value={formatNumber(sensors.ph)} + unit="" + color="var(--green)" + /> + <StatBox + icon={Activity} + label="EC" + value={formatNumber(sensors.ec)} + unit="dS/m" + color="var(--amber)" + /> + <StatBox + icon={Wind} + label="Humidity" + value={formatNumber(sensors.humidity)} + unit="%" + color="#a78bfa" + /> + </div> + + {/* Main chart */} <div - key={i} - className="flex flex-col items-center justify-center p-4 rounded-xl hover:bg-gray-50 transition border border-transparent hover:border-gray-100" + className="rounded-xl p-5" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} > <div - className={`w-12 h-12 rounded-full ${v.color} flex items-center justify-center mb-3`} + className="text-[10px] font-mono mb-4" + style={{ color: "var(--text-3)" }} > - {v.icon} - </div> - <div className="text-sm text-gray-500">{v.label}</div> - <div className="text-2xl font-bold text-gray-900"> - {v.value} + // HISTORICAL pH TRACE </div> + <ResponsiveContainer width="100%" height={200}> + <AreaChart data={chartData}> + <defs> + <linearGradient id="phGrad2" x1="0" y1="0" x2="0" y2="1"> + <stop + offset="0%" + stopColor="#4ade80" + stopOpacity={0.3} + /> + <stop + offset="100%" + stopColor="#4ade80" + stopOpacity={0} + /> + </linearGradient> + </defs> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} + /> + <XAxis + dataKey="t" + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <YAxis + domain={[5, 7.5]} + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <Tooltip content={<CustomTooltip />} /> + <Area + type="monotone" + dataKey="ph" + stroke="var(--green)" + fill="url(#phGrad2)" + strokeWidth={2} + dot={false} + name="pH" + /> + </AreaChart> + </ResponsiveContainer> </div> - ))} - </div> - </div> - {/* MIDDLE ROW: Chart & Analysis */} - <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> - {/* LATEST AI ANALYSIS */} - <div className="bg-white rounded-2xl p-6 shadow-sm border border-emerald-100 relative overflow-hidden"> - <div className="flex gap-4 relative z-10"> - <div className="flex-none bg-emerald-500 text-white w-10 h-10 rounded-lg flex items-center justify-center"> - <Sparkles size={20} /> + {/* AI Analysis */} + <div + className="rounded-xl p-5" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + className="text-[10px] font-mono mb-3" + style={{ color: "var(--text-3)" }} + > + // LATEST AI ANALYSIS + </div> + <div className="flex items-start gap-3"> + <div + className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0" + style={{ + background: "rgba(74,222,128,0.1)", + border: "1px solid rgba(74,222,128,0.2)", + }} + > + <Zap size={14} style={{ color: "var(--green)" }} /> + </div> + <div + className="text-sm leading-relaxed" + style={{ color: "var(--text-2)" }} + > + {formatOutcome(p.outcome) || + "System monitoring active. No anomalies detected."} + </div> + </div> + {p.action_taken && p.action_taken !== "PENDING_ACTION" && ( + <div + className="mt-3 p-3 rounded-lg font-mono text-xs" + style={{ + background: "var(--bg-3)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} + > + <span style={{ color: "var(--green)" }}>ACTION: </span> + {p.action_taken?.substring(0, 200)}... + </div> + )} </div> - <div className="overflow-hidden w-full"> - <h3 className="font-bold text-gray-900 mb-2"> - Latest AI Analysis - </h3> + </> + )} - <div className="text-sm text-gray-600 leading-relaxed"> - <p className="mb-2"> - <strong>Observation:</strong>{" "} - {formatOutcome(latestPayload.outcome)} - </p> + {activeTab === "sensors" && ( + <div className="space-y-6"> + {/* Temp + Humidity */} + <div + className="rounded-xl p-5" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + className="text-[10px] font-mono mb-4" + style={{ color: "var(--text-3)" }} + > + // TEMP & HUMIDITY + </div> + <ResponsiveContainer width="100%" height={200}> + <LineChart data={chartData}> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} + /> + <XAxis + dataKey="t" + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <YAxis + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <Tooltip content={<CustomTooltip />} /> + <Line + type="monotone" + dataKey="temp" + stroke="#60a5fa" + strokeWidth={2} + dot={false} + name="Temp °C" + /> + <Line + type="monotone" + dataKey="humidity" + stroke="#a78bfa" + strokeWidth={2} + dot={false} + name="Humidity %" + /> + </LineChart> + </ResponsiveContainer> + </div> - <p className="font-bold text-xs text-gray-400 uppercase tracking-wide mb-1"> - Active Parameters: - </p> - <div className="flex flex-wrap gap-2"> - {latest.parsedAction ? ( - <> - <span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded border border-blue-100"> - pH: {formatNumber(latestSensors.ph)} - </span> - <span className="px-2 py-1 bg-orange-50 text-orange-700 text-xs rounded border border-orange-100"> - Temp: {formatNumber(latestSensors.temp)}°C - </span> - </> - ) : ( - <span className="text-gray-400 italic"> - No automated actions active. - </span> - )} - </div> + {/* EC */} + <div + className="rounded-xl p-5" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + className="text-[10px] font-mono mb-4" + style={{ color: "var(--text-3)" }} + > + // EC CONCENTRATION </div> + <ResponsiveContainer width="100%" height={180}> + <AreaChart data={chartData}> + <defs> + <linearGradient id="ecGrad2" x1="0" y1="0" x2="0" y2="1"> + <stop + offset="0%" + stopColor="#f59e0b" + stopOpacity={0.25} + /> + <stop + offset="100%" + stopColor="#f59e0b" + stopOpacity={0} + /> + </linearGradient> + </defs> + <CartesianGrid + stroke="var(--border)" + strokeDasharray="3 3" + vertical={false} + /> + <XAxis + dataKey="t" + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <YAxis + tick={{ + fontSize: 9, + fill: "var(--text-3)", + fontFamily: "DM Mono", + }} + axisLine={false} + tickLine={false} + /> + <Tooltip content={<CustomTooltip />} /> + <Area + type="monotone" + dataKey="ec" + stroke="var(--amber)" + fill="url(#ecGrad2)" + strokeWidth={2} + dot={false} + name="EC dS/m" + /> + </AreaChart> + </ResponsiveContainer> </div> </div> - </div> + )} - {/* CHART */} - <div className="lg:col-span-2 bg-white rounded-2xl p-6 shadow-sm border border-gray-100 h-80"> - <h3 className="font-bold text-gray-900 mb-4"> - Environmental Trend - </h3> - <ResponsiveContainer width="100%" height="90%"> - <LineChart data={chartData}> - <CartesianGrid - strokeDasharray="3 3" - vertical={false} - stroke="#eee" - /> - <XAxis - dataKey="time" - tick={{ fontSize: 10, fill: "#aaa" }} - axisLine={false} - tickLine={false} - /> - <YAxis - yAxisId="left" - domain={["auto", "auto"]} - tick={{ fontSize: 10 }} - axisLine={false} - tickLine={false} - label={{ - value: "Temp (°C)", - angle: -90, - position: "insideLeft", - fontSize: 10, - }} - /> - <YAxis - yAxisId="right" - orientation="right" - domain={[4, 8]} - tick={{ fontSize: 10 }} - axisLine={false} - tickLine={false} - label={{ - value: "pH", - angle: 90, - position: "insideRight", - fontSize: 10, - }} - /> - <Tooltip - contentStyle={{ - borderRadius: "8px", - border: "none", - boxShadow: "0 4px 12px rgba(0,0,0,0.1)", - }} - /> - <Line - yAxisId="left" - type="monotone" - dataKey="temp" - stroke="#10B981" - strokeWidth={3} - dot={false} - name="Temp" - /> - <Line - yAxisId="right" - type="monotone" - dataKey="ph" - stroke="#3B82F6" - strokeWidth={2} - strokeDasharray="5 5" - dot={false} - name="pH" - /> - </LineChart> - </ResponsiveContainer> - </div> - </div> - - {/* BOTTOM: Event Log */} - <div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"> - <h3 className="font-bold text-gray-900 mb-4">Historical Event Log</h3> - <div className="space-y-4"> - {[...history] - .reverse() - .slice(0, 10) - .map((h, i) => ( + {activeTab === "log" && ( + <div + className="rounded-xl overflow-hidden" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + className="p-4 border-b" + style={{ borderColor: "var(--border)" }} + > <div - key={i} - className="flex gap-4 items-start pb-4 border-b border-gray-50 last:border-0" + className="text-[10px] font-mono" + style={{ color: "var(--text-3)" }} > - <div className="w-16 text-xs text-gray-400 font-mono pt-1"> - {h.payload?.timestamp - ? new Date(h.payload.timestamp).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - : "-"} - </div> - <div> - <div className="text-sm font-bold text-gray-800"> - {h.parsedAction - ? `Adjusted pH to ${formatNumber(h.cleanSensors?.ph)} • Temp to ${formatNumber(h.cleanSensors?.temp)}°C` - : h.payload?.action_taken || "Routine Check"} - </div> - <div className="text-xs text-gray-500 mt-1"> - {formatOutcome(h.payload?.outcome)} - </div> - </div> + // EVENT LOG — {history.length} ENTRIES </div> - ))} - </div> + </div> + <div + className="divide-y" + style={{ borderColor: "var(--border)" }} + > + {[...history] + .reverse() + .slice(0, 20) + .map((h, i) => ( + <div + key={i} + className="flex gap-4 px-5 py-3 hover:bg-opacity-50 transition-colors" + style={{ + background: + i % 2 === 0 + ? "transparent" + : "rgba(255,255,255,0.01)", + }} + > + <div + className="flex items-center gap-1 text-[10px] font-mono flex-shrink-0 w-16" + style={{ color: "var(--text-3)" }} + > + <Clock size={9} /> + {h.payload?.timestamp + ? new Date(h.payload.timestamp).toLocaleTimeString( + [], + { hour: "2-digit", minute: "2-digit" }, + ) + : "--"} + </div> + <div className="flex-1 min-w-0"> + <div + className="text-xs font-mono" + style={{ color: "var(--text-2)" }} + > + pH {formatNumber(h.cleanSensors?.ph)} ·{" "} + {formatNumber(h.cleanSensors?.temp)}°C · EC{" "} + {formatNumber(h.cleanSensors?.ec)} + </div> + <div + className="text-[11px] mt-0.5 truncate" + style={{ color: "var(--text-3)" }} + > + {formatOutcome(h.payload?.outcome) || + h.payload?.action_taken || + "Routine check"} + </div> + </div> + <div + className="text-[10px] font-mono flex-shrink-0" + style={{ color: "var(--text-3)" }} + > + #{h.payload?.sequence_number || i} + </div> + </div> + ))} + </div> + </div> + )} </div> </main> </div> ); -}; - -export default CropDetails; +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx @@ -1,234 +1,571 @@ -import React, { useState, useEffect } from "react"; +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 { - LayoutGrid, - BarChart3, - Bell, - Settings, + Search, + SlidersHorizontal, + Thermometer, Droplet, + ArrowUpRight, + Clock, + ChevronDown, + X, + RefreshCw, Leaf, - Thermometer, - Brain, + Activity, } from "lucide-react"; +import Sidebar from "../components/Sidebar"; + +const STAGES = ["All", "Seedling", "Vegetative", "Flowering", "Fruiting"]; +const CROPS = ["All", "Lettuce", "Tomato", "Basil", "Spinach", "Cucumber"]; +const STATUSES = ["All", "Healthy", "Attention", "Critical"]; + +const STATUS_COLORS = { + Healthy: { + bg: "rgba(74,222,128,0.12)", + text: "var(--green)", + border: "rgba(74,222,128,0.3)", + }, + Attention: { + bg: "rgba(245,158,11,0.12)", + text: "var(--amber)", + border: "rgba(245,158,11,0.3)", + }, + Critical: { + bg: "rgba(248,113,113,0.12)", + text: "var(--red)", + border: "rgba(248,113,113,0.3)", + }, +}; + +function CropCard({ data, onClick }) { + const st = STATUS_COLORS[data.status] || STATUS_COLORS.Healthy; + const maturity = data.maturity || 40; + + return ( + <div + onClick={onClick} + className="rounded-2xl overflow-hidden cursor-pointer card-hover" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + {/* Image / Gradient header */} + <div + className="relative h-36 overflow-hidden" + style={{ background: "var(--bg-3)" }} + > + <div className="absolute inset-0 flex items-center justify-center"> + <Leaf + size={40} + style={{ color: "var(--border-bright)", opacity: 0.4 }} + /> + </div> + {data.image && ( + <img + src={data.image} + alt={data.name} + className="absolute inset-0 w-full h-full object-cover opacity-70" + onError={(e) => { + e.target.style.display = "none"; + }} + /> + )} + {/* Overlay */} + <div + className="absolute inset-0" + style={{ + background: + "linear-gradient(to top, var(--surface) 0%, transparent 60%)", + }} + /> + {/* Status badge */} + <div + className="absolute top-3 right-3 text-[10px] font-mono px-2 py-1 rounded-full" + style={{ + background: st.bg, + color: st.text, + border: `1px solid ${st.border}`, + }} + > + {data.status === "Healthy" + ? "● " + : data.status === "Critical" + ? "▲ " + : "◆ "} + {data.status.toUpperCase()} + </div> + {/* Seq number */} + <div + className="absolute top-3 left-3 text-[10px] font-mono px-2 py-0.5 rounded" + style={{ background: "rgba(0,0,0,0.5)", color: "var(--text-3)" }} + > + #{data.seq || 1} + </div> + </div> + + <div className="p-4 space-y-3"> + {/* Name + stage */} + <div> + <div className="font-bold text-sm" style={{ color: "var(--text)" }}> + {data.name} + </div> + <div + className="text-[11px] mt-0.5 font-mono" + style={{ color: "var(--text-3)" }} + > + {data.cropId} · {data.statusMsg} + </div> + </div> + + {/* Maturity bar */} + <div> + <div + className="flex justify-between text-[10px] font-mono mb-1" + style={{ color: "var(--text-3)" }} + > + <span>Maturity</span> + <span style={{ color: "var(--green)" }}>{maturity}%</span> + </div> + <div + className="h-1 rounded-full" + style={{ background: "var(--border)" }} + > + <div + className="h-full rounded-full progress-fill" + style={{ + width: `${maturity}%`, + background: + maturity > 70 + ? "var(--green)" + : maturity > 40 + ? "var(--amber)" + : "var(--text-3)", + }} + /> + </div> + </div> -const Dashboard = () => { + {/* Sensor row */} + <div className="grid grid-cols-2 gap-2 pt-1"> + <div className="flex items-center gap-1.5"> + <Thermometer size={12} style={{ color: "var(--text-3)" }} /> + <span + className="text-xs font-mono" + style={{ color: "var(--text-2)" }} + > + {data.sensors.temp}°C + </span> + </div> + <div className="flex items-center gap-1.5"> + <Droplet size={12} style={{ color: "var(--text-3)" }} /> + <span + className="text-xs font-mono" + style={{ color: "var(--text-2)" }} + > + pH {data.sensors.ph} + </span> + </div> + </div> + + {/* Footer */} + <div + className="flex items-center justify-between pt-1 border-t" + style={{ borderColor: "var(--border)" }} + > + <div + className="flex items-center gap-1 text-[10px]" + style={{ color: "var(--text-3)" }} + > + <Clock size={10} /> + {data.daysLeft > 0 ? `${data.daysLeft}d left` : "Ready"} + </div> + <ArrowUpRight size={14} style={{ color: "var(--text-3)" }} /> + </div> + </div> + </div> + ); +} + +export default function Dashboard() { + const navigate = useNavigate(); const [crops, setCrops] = useState([]); const [loading, setLoading] = useState(true); - const navigate = useNavigate(); + 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); - useEffect(() => { - const loadData = async () => { - const data = await fetchDashboardData(); - - if (data && data.length > 0) { - // --- DATA MAPPING --- - const formattedData = data.map((item) => { + const loadData = async () => { + setRefreshing(true); + const data = await fetchDashboardData(); + if (data?.length > 0) { + setCrops( + data.map((item) => { const p = item.payload || {}; const sensors = extractSensors(p); - return { id: p.crop_id || item.id, - name: p.crop || "Unknown Crop", - location: p.location || "Unit A-1 • Hydroponic", - image: getImageForCrop(p.crop), + cropId: p.crop_id || "—", + name: p.crop || "Unknown", + statusMsg: p.stage || "Growing", + image: getImg(p.crop), status: p.outcome === "CRITICAL" ? "Critical" : p.action_taken === "PENDING_ACTION" ? "Attention" : "Healthy", - statusMsg: p.stage || "Growing", maturity: calculateMaturity(p.sequence_number), + seq: p.sequence_number, daysLeft: 30 - (p.sequence_number || 0), - sensors: { - temp: `${sensors.temp}°C`, - ph: sensors.ph, - }, + sensors: { temp: sensors.temp, ph: sensors.ph }, + stage: p.stage || "", + rawCrop: (p.crop || "").trim(), }; - }); - setCrops(formattedData); - } else { - setCrops([]); - } - setLoading(false); - }; + }), + ); + } + setLoading(false); + setRefreshing(false); + }; + useEffect(() => { loadData(); }, []); - const getImageForCrop = (name) => { - if (!name) - return "https://images.unsplash.com/photo-1618164436241-4473940d1f5c?q=80&w=2000"; + const getImg = (name) => { + if (!name) return null; const n = name.toLowerCase(); - if (n.includes("basil")) - return "https://images.unsplash.com/photo-1618164436241-4473940d1f5c?q=80&w=2000"; if (n.includes("tomato")) - return "https://images.unsplash.com/photo-1591857177580-dc82b9e4e5c9?q=80&w=2000"; + return "https://images.unsplash.com/photo-1591857177580-dc82b9e4e5c9?q=80&w=400"; + if (n.includes("basil")) + return "https://images.unsplash.com/photo-1618164436241-4473940d1f5c?q=80&w=400"; if (n.includes("spinach")) - return "https://images.unsplash.com/photo-1576045057995-568f588f82fb?q=80&w=2000"; - if (n.includes("straw")) - return "https://images.unsplash.com/photo-1601004890684-d8cbf643f5f2?q=80&w=2000"; - return "https://images.unsplash.com/photo-1622206151226-18ca2c9ab4a1?q=80&w=2000"; + return "https://images.unsplash.com/photo-1576045057995-568f588f82fb?q=80&w=400"; + return "https://images.unsplash.com/photo-1622206151226-18ca2c9ab4a1?q=80&w=400"; }; + const filtered = useMemo(() => { + return crops.filter((c) => { + const q = search.toLowerCase(); + if ( + q && + !c.name.toLowerCase().includes(q) && + !c.cropId.toLowerCase().includes(q) && + !c.statusMsg.toLowerCase().includes(q) + ) + return false; + if (filterStage !== "All" && c.stage !== filterStage) return false; + if (filterCrop !== "All" && c.rawCrop !== filterCrop) return false; + if (filterStatus !== "All" && c.status !== filterStatus) return false; + return true; + }); + }, [crops, search, filterStage, filterCrop, filterStatus]); + + const activeFilters = [filterStage, filterCrop, filterStatus].filter( + (f) => f !== "All", + ).length; + + const summary = useMemo( + () => ({ + total: crops.length, + healthy: crops.filter((c) => c.status === "Healthy").length, + attention: crops.filter((c) => c.status === "Attention").length, + critical: crops.filter((c) => c.status === "Critical").length, + }), + [crops], + ); + return ( - <div className="flex h-screen bg-[#F4F9F6] font-sans text-gray-800"> - {/* SIDEBAR */} - <aside className="w-64 bg-white border-r border-gray-100 flex flex-col justify-between hidden md:flex"> - <div> - <div className="p-6 flex items-center gap-3"> - <div className="bg-emerald-500 p-1.5 rounded-lg text-white"> - <Leaf size={20} fill="currentColor" /> - </div> - <h1 className="text-xl font-bold tracking-tight">Demeter</h1> - </div> - <nav className="px-4 space-y-1"> - <SidebarItem - icon={<LayoutGrid size={20} />} - label="My Crops" - active - /> - <SidebarItem icon={<BarChart3 size={20} />} label="Analytics" /> - <SidebarItem icon={<Bell size={20} />} label="Alerts" /> - <SidebarItem icon={<Settings size={20} />} label="Settings" /> - </nav> - </div> - <div className="p-4 border-t border-gray-50"> - <div className="flex items-center gap-3 p-2 rounded-xl"> - <div className="w-10 h-10 rounded-full bg-orange-100 flex items-center justify-center text-orange-600 font-bold"> - RR - </div> - <div className="flex-1"> - <h4 className="text-sm font-bold text-gray-900">Rajesh Rai</h4> - <p className="text-xs text-gray-500">Owner</p> - </div> + <div + className="flex h-screen overflow-hidden" + style={{ background: "var(--bg)" }} + > + <Sidebar /> + + <main className="flex-1 flex flex-col overflow-hidden"> + {/* 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)" }} + > + <div> + <h1 className="font-bold text-lg" style={{ color: "var(--text)" }}> + Crops Overview + </h1> + <p className="text-xs font-mono" style={{ color: "var(--text-3)" }}> + {filtered.length} of {crops.length} crops shown + </p> </div> - </div> - </aside> - {/* MAIN CONTENT */} - <main className="flex-1 flex flex-col h-full overflow-hidden"> - {/* --- HEADER (Updated with Button) --- */} - <header className="h-20 px-8 flex items-center justify-between bg-white border-b border-gray-50"> - <h2 className="text-lg font-bold text-gray-800">Crops Overview</h2> + {/* Summary chips */} + <div className="hidden md:flex items-center gap-2 ml-4"> + {[ + { + label: "Healthy", + count: summary.healthy, + color: "var(--green)", + }, + { + label: "Attention", + count: summary.attention, + color: "var(--amber)", + }, + { + label: "Critical", + count: summary.critical, + color: "var(--red)", + }, + ].map(({ label, count, color }) => ( + <div + key={label} + className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-mono" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + color, + }} + > + <span>{count}</span> + <span style={{ opacity: 0.6 }}>{label}</span> + </div> + ))} + </div> - <div className="flex items-center gap-4"> - {/* NEW BUTTON FOR AGENT CONTROL */} + <div className="ml-auto flex items-center gap-2"> <button - onClick={() => navigate("/control")} - className="flex items-center gap-2 bg-white border border-emerald-100 text-emerald-600 hover:bg-emerald-50 px-4 py-2 rounded-lg text-sm font-bold transition-colors shadow-sm hover:shadow-md" + onClick={loadData} + className="p-2 rounded-lg transition-colors" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} > - <Brain size={18} /> Agent Control + <RefreshCw + size={15} + className={refreshing ? "animate-spin" : ""} + /> </button> + </div> + </header> + + {/* Search + Filter bar */} + <div + className="flex-shrink-0 px-6 py-3 border-b flex items-center gap-3" + style={{ borderColor: "var(--border)", background: "var(--bg-2)" }} + > + {/* Search */} + <div className="relative flex-1 max-w-md"> + <Search + size={14} + className="absolute left-3 top-1/2 -translate-y-1/2" + style={{ color: "var(--text-3)" }} + /> + <input + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search crops, IDs, stages…" + className="w-full pl-9 pr-4 py-2 rounded-lg text-sm font-mono outline-none transition-all" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text)", + caretColor: "var(--green)", + }} + /> + {search && ( + <button + onClick={() => setSearch("")} + className="absolute right-3 top-1/2 -translate-y-1/2" + > + <X size={12} style={{ color: "var(--text-3)" }} /> + </button> + )} + </div> - <div className="w-px h-6 bg-gray-200 mx-2"></div> + {/* Filter toggle */} + <button + onClick={() => setShowFilters(!showFilters)} + className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-all" + style={{ + background: showFilters + ? "rgba(74,222,128,0.1)" + : "var(--surface)", + border: `1px solid ${showFilters ? "rgba(74,222,128,0.3)" : "var(--border)"}`, + color: showFilters ? "var(--green)" : "var(--text-2)", + }} + > + <SlidersHorizontal size={14} /> + Filters + {activeFilters > 0 && ( + <span + className="px-1.5 py-0.5 rounded text-[10px] font-mono" + style={{ background: "var(--green)", color: "#0c1a0e" }} + > + {activeFilters} + </span> + )} + </button> - <div className="flex items-center gap-2 bg-emerald-50 px-3 py-1.5 rounded-full text-xs font-semibold text-emerald-700"> - <span className="w-2 h-2 rounded-full bg-emerald-500"></span>{" "} - System online - </div> + {/* Quick category pills */} + <div className="hidden lg:flex items-center gap-2"> + {STAGES.slice(0, 4).map((s) => ( + <button + key={s} + onClick={() => setFilterStage(filterStage === s ? "All" : s)} + className="px-3 py-1.5 rounded-full text-[11px] font-mono transition-all" + style={{ + background: + filterStage === s + ? "rgba(74,222,128,0.15)" + : "var(--surface)", + border: `1px solid ${filterStage === s ? "rgba(74,222,128,0.4)" : "var(--border)"}`, + color: filterStage === s ? "var(--green)" : "var(--text-3)", + }} + > + {s} + </button> + ))} </div> - </header> + </div> + + {/* Expanded filters */} + {showFilters && ( + <div + className="flex-shrink-0 px-6 py-3 border-b flex items-center gap-6 animate-fade-in" + style={{ borderColor: "var(--border)", background: "var(--bg-3)" }} + > + {[ + { + label: "Crop Type", + value: filterCrop, + set: setFilterCrop, + opts: CROPS, + }, + { + label: "Stage", + value: filterStage, + set: setFilterStage, + opts: STAGES, + }, + { + label: "Status", + value: filterStatus, + set: setFilterStatus, + opts: STATUSES, + }, + ].map(({ label, value, set, opts }) => ( + <div key={label} className="flex items-center gap-2"> + <span + className="text-[11px] font-mono" + style={{ color: "var(--text-3)" }} + > + {label} + </span> + <div className="relative"> + <select + value={value} + onChange={(e) => set(e.target.value)} + className="appearance-none pl-3 pr-7 py-1.5 rounded-lg text-xs font-mono outline-none cursor-pointer" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-2)", + }} + > + {opts.map((o) => ( + <option key={o} value={o}> + {o} + </option> + ))} + </select> + <ChevronDown + size={10} + className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" + style={{ color: "var(--text-3)" }} + /> + </div> + </div> + ))} + <button + onClick={() => { + setFilterStage("All"); + setFilterCrop("All"); + setFilterStatus("All"); + setSearch(""); + }} + className="ml-auto text-[11px] font-mono transition-colors" + style={{ color: "var(--text-3)" }} + > + Clear all + </button> + </div> + )} - <div className="flex-1 overflow-y-auto p-8"> + {/* Crop grid */} + <div className="flex-1 overflow-y-auto p-6"> {loading ? ( - <div className="flex h-full items-center justify-center text-gray-400"> - Loading Farm Data... + <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> + {Array(8) + .fill(0) + .map((_, i) => ( + <div + key={i} + className="h-64 rounded-2xl shimmer" + style={{ border: "1px solid var(--border)" }} + /> + ))} + </div> + ) : filtered.length > 0 ? ( + <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> + {filtered.map((crop) => ( + <CropCard + key={crop.id} + data={crop} + onClick={() => navigate(`/crop/${crop.id}`)} + /> + ))} </div> ) : ( - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> - {crops.length > 0 ? ( - crops.map((crop) => ( - <div - key={crop.id} - onClick={() => navigate(`/crop/${crop.id}`)} - className="cursor-pointer" - > - <CropCard data={crop} /> - </div> - )) - ) : ( - <div className="col-span-full text-center text-gray-400 mt-20"> - No crops found in database. Start the simulation to see data. - </div> - )} + <div className="flex flex-col items-center justify-center h-full gap-4"> + <div + className="w-16 h-16 rounded-2xl flex items-center justify-center" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <Activity size={28} style={{ color: "var(--text-3)" }} /> + </div> + <div style={{ color: "var(--text-2)" }}> + No crops match your filters + </div> + <button + onClick={() => { + setSearch(""); + setFilterStage("All"); + setFilterCrop("All"); + setFilterStatus("All"); + }} + className="text-xs font-mono px-4 py-2 rounded-lg" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} + > + Clear filters + </button> </div> )} </div> </main> </div> ); -}; - -// Sub-components -const SidebarItem = ({ icon, label, active }) => ( - <div - className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${active ? "bg-emerald-50 text-emerald-700 font-semibold" : "text-gray-500 hover:bg-gray-50"}`} - > - {icon} <span className="flex-1 text-sm">{label}</span> - </div> -); - -const CropCard = ({ data }) => { - const isHealthy = data.status === "Healthy"; - const progressColor = isHealthy ? "bg-emerald-500" : "bg-orange-500"; - - return ( - <div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100 hover:shadow-md transition group"> - <div className="relative h-40 rounded-xl overflow-hidden mb-4"> - <img - src={data.image} - alt={data.name} - className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" - /> - <div className="absolute top-3 right-3 px-2.5 py-1 rounded-md text-xs font-bold bg-white/90 text-emerald-700 backdrop-blur-md"> - {data.statusMsg} - </div> - </div> - <div className="space-y-4"> - <div> - <h3 className="text-lg font-bold text-gray-900 leading-tight"> - {data.name} - </h3> - <p className="text-xs text-gray-400 mt-1">{data.location}</p> - </div> - <div> - <div className="flex justify-between text-xs font-semibold mb-1.5"> - <span className="text-gray-500">Maturity</span> - <span className="text-emerald-600">{data.daysLeft} days left</span> - </div> - <div className="h-1.5 w-full bg-gray-100 rounded-full"> - <div - className={`h-full rounded-full ${progressColor}`} - style={{ width: `${data.maturity}%` }} - ></div> - </div> - </div> - <div className="grid grid-cols-2 gap-4 pt-2 border-t border-gray-50"> - <SensorItem - icon={<Thermometer size={14} />} - value={data.sensors.temp} - label="Temp" - /> - <SensorItem - icon={<Droplet size={14} />} - value={data.sensors.ph} - label="pH" - /> - </div> - </div> - </div> - ); -}; - -const SensorItem = ({ icon, value, label }) => ( - <div className="text-center p-2 rounded-lg bg-gray-50"> - <div className="flex justify-center text-gray-400 mb-1">{icon}</div> - <div className="text-sm font-bold text-gray-700">{value}</div> - <div className="text-[10px] text-gray-400 uppercase">{label}</div> - </div> -); - -export default Dashboard; +} diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx @@ -1,181 +1,558 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Leaf, + ArrowRight, + Cpu, Database, - Moon, + Eye, Zap, - Droplet, - Cpu, Activity, - Rocket, - BrainCircuit, } from "lucide-react"; +import { fetchDashboardData } from "../api/farmApi"; +import { extractSensors } from "../utils/dataUtils"; + +const FEATURES = [ + { + icon: Cpu, + label: "Reinforcement Learning", + desc: "Contextual bandit that learns with every cycle", + }, + { + icon: Eye, + label: "Computer Vision", + desc: "Azure CV-powered disease detection", + }, + { + icon: Database, + label: "Vector Memory", + desc: "Qdrant-backed long-term plant biographies", + }, + { + icon: Zap, + label: "Physics Simulation", + desc: "LLM-based digital twin before every action", + }, +]; + +// Compute fleet-wide averages from dashboard data +function computeFleetStats(dashData) { + if (!dashData?.length) return null; + const sensors = dashData.map((d) => extractSensors(d.payload)); + const avg = (arr) => + arr.reduce((s, v) => s + parseFloat(v || 0), 0) / arr.length; + + const ph = avg(sensors.map((s) => s.ph)); + const ec = avg(sensors.map((s) => s.ec)); + const temp = avg(sensors.map((s) => s.temp)); + const humidity = avg(sensors.map((s) => s.humidity)); + + const totalSeqs = dashData.reduce( + (s, d) => s + (d.payload?.sequence_number || 0), + 0, + ); + const cropTypes = [ + ...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; + + return { + ph: ph.toFixed(2), + ec: ec.toFixed(2), + temp: temp.toFixed(1), + humidity: humidity.toFixed(1), + cropCount: dashData.length, + totalSeqs, + cropTypes, + alerts, + }; +} -const LandingPage = () => { +// Build a real activity log from the last N payloads +function buildActivityLog(dashData) { + if (!dashData?.length) return []; + return dashData + .slice(-5) + .reverse() + .map((d, i) => { + const p = d.payload || {}; + const agent = (p.strategic_intent || "SUPERVISOR") + .replace(/_/g, " ") + .split(" ")[0]; + const msg = p.strategic_intent + ? `Strategy: ${p.strategic_intent.replace(/_/g, " ")}` + : `Monitoring ${p.crop || "crop"} — seq #${p.sequence_number || 1}`; + const ts = p.timestamp + ? new Date(p.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + : `0${i}:0${i}`; + return { agent, msg, time: ts }; + }); +} + +export default function LandingPage() { const navigate = useNavigate(); + const [mounted, setMounted] = useState(false); + 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 + const headlineStats = stats + ? [ + { val: stats.cropCount.toString(), label: "Active Crops" }, + { val: stats.cropTypes.length.toString(), label: "Crop Types" }, + { val: stats.totalSeqs.toString(), label: "Total Cycles" }, + { + val: stats.alerts > 0 ? stats.alerts.toString() : "0", + label: "Active Alerts", + }, + ] + : [ + { val: "—", label: "Active Crops" }, + { val: "—", label: "Crop Types" }, + { val: "—", label: "Total Cycles" }, + { val: "—", label: "Active Alerts" }, + ]; + + // Live sensor readings from real data + const readings = stats + ? [ + { + label: "AVG pH", + value: stats.ph, + ok: parseFloat(stats.ph) >= 5.5 && parseFloat(stats.ph) <= 6.5, + }, + { + label: "AVG EC", + value: `${stats.ec}`, + ok: parseFloat(stats.ec) <= 2.5, + }, + { + label: "TEMP", + value: `${stats.temp}°C`, + ok: parseFloat(stats.temp) >= 18 && parseFloat(stats.temp) <= 30, + }, + { + label: "HUMIDITY", + value: `${stats.humidity}%`, + ok: + parseFloat(stats.humidity) >= 40 && + parseFloat(stats.humidity) <= 80, + }, + ] + : [ + { label: "AVG pH", value: "—", ok: true }, + { label: "AVG EC", value: "—", ok: true }, + { label: "TEMP", value: "—", ok: true }, + { label: "HUMIDITY", value: "—", ok: true }, + ]; return ( - // CHANGE 1: h-screen and max-h-screen forces one page, no scrolling. - <div className="h-screen max-h-screen relative font-sans text-gray-800 overflow-hidden flex flex-col"> - {/* BACKGROUND IMAGE LAYER */} + <div + className="min-h-screen grid-bg relative overflow-hidden" + style={{ background: "var(--bg)" }} + > + <div className="scanline" /> + + {/* Ambient glow */} <div - className="absolute inset-0 z-0" + className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px] pointer-events-none" style={{ - // CHANGE 2: Referencing the file directly from the public folder - backgroundImage: "url('/background.png')", - backgroundSize: "cover", - backgroundPosition: "center", + background: + "radial-gradient(ellipse at center, rgba(74,222,128,0.06) 0%, transparent 70%)", }} - > - {/* White Overlay Gradient */} - <div className="absolute inset-0 bg-gradient-to-r from-white via-white/80 to-transparent/30"></div> - </div> + /> - {/* --- NAVBAR --- */} - {/* Reduced vertical padding (py-4) to save space */} - <nav className="relative z-10 flex-none flex items-center justify-between px-8 py-4"> - {/* Logo */} - <div className="flex items-center gap-2"> - <div className="bg-emerald-500 p-2 rounded-lg text-white"> - <Leaf size={24} fill="currentColor" /> + {/* Nav */} + <nav + className="relative z-10 flex items-center justify-between px-8 py-5 border-b" + style={{ borderColor: "rgba(74,222,128,0.1)" }} + > + <div className="flex items-center gap-3"> + <div + className="w-9 h-9 rounded-xl flex items-center justify-center" + style={{ background: "linear-gradient(135deg, #1a5c2d, #4ade80)" }} + > + <Leaf size={18} fill="white" color="white" /> </div> <div> - <h1 className="text-xl font-bold tracking-tight text-gray-900"> - Demeter - </h1> - <p className="text-[10px] text-gray-500 tracking-widest uppercase"> - Agentic Cultivating AI - </p> + <div + className="font-bold text-base tracking-tight" + style={{ color: "var(--text)" }} + > + DEMETER + </div> + <div + className="text-[9px] font-mono tracking-[0.2em]" + style={{ color: "var(--text-3)" }} + > + AUTONOMOUS FARM INTELLIGENCE + </div> </div> </div> - {/* Status Badges */} <div className="flex items-center gap-4"> - <div className="hidden md:flex items-center gap-2 bg-emerald-100/80 backdrop-blur-sm border border-emerald-200 px-3 py-1 rounded-full text-xs font-semibold text-emerald-800"> - <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span> - SYSTEM ONLINE - </div> - - <div className="hidden md:flex items-center gap-2 bg-blue-100/80 backdrop-blur-sm border border-blue-200 px-3 py-1 rounded-full text-xs font-semibold text-blue-800"> - <Database size={12} /> - QDRANT CONNECTED + <div + className="flex items-center gap-2 text-[11px] font-mono px-3 py-1.5 rounded-full" + style={{ + border: "1px solid rgba(74,222,128,0.3)", + color: "var(--green)", + background: "rgba(74,222,128,0.05)", + }} + > + <span + className="status-dot w-1.5 h-1.5 rounded-full" + style={{ background: "var(--green)" }} + /> + {loading ? "CONNECTING…" : stats ? "FARM ONLINE" : "NO DATA"} </div> - - <button className="p-2 rounded-full bg-gray-100/50 hover:bg-gray-200/50 backdrop-blur-md transition"> - <Moon size={20} className="text-gray-600" /> + <button + onClick={() => navigate("/dashboard")} + className="px-4 py-2 rounded-lg text-sm font-semibold transition-all" + style={{ + background: "var(--surface)", + color: "var(--text-2)", + border: "1px solid var(--border)", + }} + > + Dashboard </button> </div> </nav> - {/* --- MAIN CONTENT --- */} - {/* flex-1 ensures this takes up remaining height, grid centers vertically */} - <main className="relative z-10 flex-1 container mx-auto px-8 grid grid-cols-1 lg:grid-cols-2 gap-8 items-center h-full"> - {/* LEFT COLUMN: Text & Features */} - <div className="space-y-6 animate-fade-in-up"> - {/* Version Badge */} - <div className="inline-flex items-center gap-2 bg-emerald-50/80 border border-emerald-200 text-emerald-700 px-3 py-1 rounded-full text-xs font-bold tracking-wide uppercase"> - <Zap size={12} fill="currentColor" /> - Autonomous Agriculture v2.0 - </div> + {/* Hero */} + <div className="relative z-10 max-w-7xl mx-auto px-8 pt-24 pb-16"> + <div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center"> + {/* Left */} + <div + className={`space-y-8 ${mounted ? "animate-fade-up" : "opacity-0"}`} + > + <div + className="inline-flex items-center gap-2 text-[11px] font-mono px-3 py-1.5 rounded-full" + style={{ + border: "1px solid rgba(245,158,11,0.4)", + color: "var(--amber)", + background: "rgba(245,158,11,0.06)", + }} + > + <Zap size={10} fill="currentColor" /> + MULTI-AGENT SYSTEM · LANGGRAPH · QDRANT + </div> - {/* Headlines - Slightly tighter leading */} - <div className="space-y-1"> - <h1 className="text-5xl lg:text-6xl font-extrabold text-gray-900 leading-tight"> - The Future of <br /> - <span className="text-emerald-500">Farming</span> is Here. + <h1 + className="text-6xl lg:text-7xl font-bold leading-[1.0] tracking-tight" + style={{ color: "var(--text)" }} + > + The Farm + <br /> + <span + className="font-serif italic" + style={{ color: "var(--green)" }} + > + Thinks + </span> + <br /> + For Itself. </h1> - <p className="text-xl text-gray-600 font-light"> - Smarter, Faster, Sustainable. + + <p + className="text-lg leading-relaxed max-w-md" + style={{ color: "var(--text-2)", fontWeight: 300 }} + > + Demeter is a cognitive hydroponic system. Seven specialized AI + agents collaborate to perceive, reason, and act — optimizing your + crops 24/7 without human intervention. </p> - </div> - {/* Feature List - Compact Grid for fitting screen */} - <div className="grid grid-cols-1 gap-3 pr-4"> - <FeatureItem - icon={<Zap className="text-emerald-600" size={18} />} - title="Higher Yields" - desc="Up to 10x more produce with accelerated cycles." - /> - <FeatureItem - icon={<Droplet className="text-blue-500" size={18} />} - title="Resource Efficient" - desc="90% less water, zero soil erosion." - /> - <FeatureItem - icon={<Cpu className="text-teal-600" size={18} />} - title="AI-Driven Precision" - desc="Agentic agents optimize climate in real-time." - /> - <FeatureItem - icon={<Activity className="text-purple-600" size={18} />} - title="Data-Powered Insights" - desc="Instant anomaly detection via Vector Search." - /> - </div> + <div className="flex gap-4"> + <button + onClick={() => navigate("/dashboard")} + 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 + <ArrowRight + size={16} + className="group-hover:translate-x-1 transition-transform" + /> + </button> + <button + onClick={() => navigate("/control")} + className="flex items-center gap-3 px-7 py-3.5 rounded-xl font-semibold text-sm transition-all" + style={{ + border: "1px solid var(--border)", + color: "var(--text-2)", + background: "var(--surface)", + }} + > + <Cpu size={16} /> + Agent Control + </button> + </div> - {/* CTA Button */} - <div className="pt-2"> - <button - onClick={() => navigate("/dashboard")} - className="group flex items-center gap-3 bg-emerald-500 hover:bg-emerald-600 text-white text-lg font-bold px-8 py-3 rounded-full shadow-lg shadow-emerald-500/30 transition-all transform hover:-translate-y-1" + {/* Live headline stats from DB */} + <div + className="grid grid-cols-4 gap-4 pt-4 border-t" + style={{ borderColor: "var(--border)" }} > - <Rocket - size={20} - className="group-hover:rotate-12 transition-transform" - /> - Try Demeter Now - </button> + {headlineStats.map(({ val, label }) => ( + <div key={label}> + <div + className="text-2xl font-bold font-mono" + style={{ color: "var(--green)" }} + > + {loading ? ( + <span className="inline-block w-8 h-6 rounded shimmer" /> + ) : ( + val + )} + </div> + <div + className="text-[11px] mt-0.5" + style={{ color: "var(--text-3)" }} + > + {label} + </div> + </div> + ))} + </div> </div> - </div> - {/* RIGHT COLUMN: Visual HUD Elements */} - {/* Centered and scaled to fit without scrolling */} - <div className="hidden lg:flex relative h-full justify-center items-center scale-90 origin-center"> - {/* Circular Radar Overlay */} - <div className="absolute w-[450px] h-[450px] border border-emerald-500/20 rounded-full animate-[spin_10s_linear_infinite]"></div> - <div className="absolute w-[300px] h-[300px] border border-emerald-500/40 rounded-full border-dashed animate-[spin_15s_linear_infinite_reverse]"></div> + {/* Right — live HUD */} + <div + className={`relative ${mounted ? "animate-fade-in" : "opacity-0"}`} + > + <div + className="rounded-2xl overflow-hidden" + style={{ + border: "1px solid var(--border)", + background: "var(--bg-2)", + }} + > + {/* Terminal header */} + <div + className="flex items-center gap-2 px-4 py-3 border-b" + style={{ + borderColor: "var(--border)", + background: "var(--bg-3)", + }} + > + <div + className="w-3 h-3 rounded-full" + style={{ background: "#ff5f57" }} + /> + <div + className="w-3 h-3 rounded-full" + style={{ background: "#ffbd2e" }} + /> + <div + className="w-3 h-3 rounded-full" + style={{ background: "#28ca41" }} + /> + <span + className="ml-2 text-[11px] font-mono" + style={{ color: "var(--text-3)" }} + > + demeter://live-feed + </span> + <Activity + size={11} + className="ml-auto" + style={{ color: "var(--green)" }} + /> + </div> - {/* Central AI Brain Node */} - <div className="relative z-20 w-24 h-24 bg-white/40 backdrop-blur-md rounded-full flex items-center justify-center shadow-xl border border-white/50"> - <BrainCircuit size={48} className="text-white drop-shadow-md" /> - </div> + <div className="p-6 space-y-5"> + {/* Live sensor grid */} + <div className="grid grid-cols-2 gap-3"> + {readings.map(({ label, value, ok }) => ( + <div + key={label} + className="rounded-xl p-4" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + className="text-[10px] font-mono mb-2" + style={{ color: "var(--text-3)" }} + > + {label} + </div> + {loading ? ( + <div className="h-7 w-16 rounded shimmer" /> + ) : ( + <div + className="text-2xl font-mono font-bold" + style={{ color: ok ? "var(--green)" : "var(--red)" }} + > + {value} + </div> + )} + <div + className="text-[9px] font-mono mt-1" + style={{ + color: ok + ? "rgba(74,222,128,0.6)" + : "rgba(248,113,113,0.6)", + }} + > + {ok ? "● OPTIMAL" : "● ALERT"} + </div> + </div> + ))} + </div> - {/* Floating Data Cards */} - <div className="absolute top-[20%] right-[10%] bg-white/80 backdrop-blur-sm border border-emerald-100 p-3 rounded-lg shadow-lg flex items-center gap-3 animate-bounce-slow"> - <div className="h-2 w-2 bg-emerald-500 rounded-full"></div> - <span className="text-sm font-mono text-emerald-800 font-bold"> - pH: Optimal - </span> - </div> + {/* Agent activity feed */} + <div + className="rounded-xl p-4" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + className="text-[10px] font-mono mb-3" + style={{ color: "var(--text-3)" }} + > + RECENT AGENT ACTIVITY + </div> + <div className="space-y-2"> + {loading ? ( + [1, 2, 3].map((i) => ( + <div key={i} className="h-4 rounded shimmer" /> + )) + ) : log.length > 0 ? ( + log.slice(0, 3).map(({ agent, msg, time }, i) => ( + <div + key={i} + className="flex items-start gap-2 text-[11px] font-mono" + > + <span style={{ color: "var(--green)", opacity: 0.6 }}> + {time} + </span> + <span + className="px-1.5 py-0.5 rounded text-[9px]" + style={{ + background: "rgba(74,222,128,0.12)", + color: "var(--green)", + whiteSpace: "nowrap", + }} + > + {agent.substring(0, 12)} + </span> + <span style={{ color: "var(--text-2)" }}> + {msg.substring(0, 40)} + {msg.length > 40 ? "…" : ""} + </span> + </div> + )) + ) : ( + <div + className="text-[11px] font-mono" + style={{ color: "var(--text-3)" }} + > + No cycles recorded yet. Run the agent loop to see + activity. + </div> + )} + <div + className="flex items-center gap-1 text-[11px] font-mono cursor-blink" + style={{ color: "var(--green)" }} + > + <span style={{ opacity: 0.4 }}>→ </span> + </div> + </div> + </div> + </div> + </div> - <div className="absolute bottom-[25%] right-[5%] bg-white/80 backdrop-blur-sm border border-blue-100 p-3 rounded-lg shadow-lg flex items-center gap-3 animate-bounce-slower"> - <span className="text-sm font-mono text-blue-800 font-bold"> - Humidity: 65% - </span> + {/* Live crop count chip */} + {stats && ( + <div + className="absolute -top-4 -right-4 px-3 py-2 rounded-lg text-[11px] font-mono" + style={{ + background: "var(--surface-2)", + border: "1px solid var(--border)", + color: "var(--amber)", + }} + > + ⬆ {stats.cropCount} crop{stats.cropCount !== 1 ? "s" : ""}{" "} + monitored + </div> + )} </div> </div> - </main> - {/* --- FOOTER --- */} - <footer className="relative z-10 flex-none w-full text-center py-4 text-gray-500 text-xs"> - © 2026 Demeter AI Systems. Revolutionizing Hydroponics. - </footer> + {/* Feature grid */} + <div + className="mt-24 pt-12 border-t" + style={{ borderColor: "var(--border)" }} + > + <div + className="text-[11px] font-mono mb-8" + style={{ color: "var(--text-3)" }} + > + // CORE CAPABILITIES + </div> + <div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> + {FEATURES.map(({ icon: Icon, label, desc }) => ( + <div + key={label} + className="p-5 rounded-xl card-hover" + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + className="w-9 h-9 rounded-lg flex items-center justify-center mb-4" + style={{ + background: "rgba(74,222,128,0.1)", + border: "1px solid rgba(74,222,128,0.2)", + }} + > + <Icon size={18} style={{ color: "var(--green)" }} /> + </div> + <div + className="font-semibold text-sm mb-1" + style={{ color: "var(--text)" }} + > + {label} + </div> + <div + className="text-xs leading-relaxed" + style={{ color: "var(--text-3)" }} + > + {desc} + </div> + </div> + ))} + </div> + </div> + </div> </div> ); -}; - -// Compact Feature Item -const FeatureItem = ({ icon, title, desc }) => ( - <div className="flex gap-3 items-center p-2 rounded-xl hover:bg-white/40 transition-colors cursor-default"> - <div className="bg-white p-1.5 rounded-lg shadow-sm">{icon}</div> - <div> - <h3 className="text-base font-bold text-gray-900 leading-tight"> - {title} - </h3> - <p className="text-xs text-gray-600 leading-snug">{desc}</p> - </div> - </div> -); - -export default LandingPage; +} diff --git a/frontend/src/utils/dataUtils.js b/frontend/src/utils/dataUtils.js @@ -6,19 +6,17 @@ export const formatNumber = (val) => { export const parsePythonString = (str) => { if (!str) return null; if (typeof str === "object") return str; - try { return JSON.parse(str); - } catch (e) { + } catch { try { - // Fix Python single quotes and Booleans - const fixedStr = str + const fixed = str .replace(/'/g, '"') .replace(/\bNone\b/g, "null") .replace(/\bFalse\b/g, "false") .replace(/\bTrue\b/g, "true"); - return JSON.parse(fixedStr); - } catch (e2) { + return JSON.parse(fixed); + } catch { return null; } } @@ -26,32 +24,25 @@ export const parsePythonString = (str) => { export const extractSensors = (payload) => { if (!payload) return { temp: 0, ph: 0, humidity: 0, ec: 0 }; - - let rawSensors = payload.sensors || payload.sensor_data; - - // Fallback to extracting from action_taken if sensors are missing - if (!rawSensors) { - const actionData = parsePythonString(payload.action_taken); - if (actionData) { - rawSensors = { - temp: - actionData.atmospheric_actions?.air_temp ?? actionData.air_temp ?? 0, - ph: actionData.water_actions?.ph ?? actionData.ph ?? 0, - humidity: - actionData.atmospheric_actions?.humidity ?? actionData.humidity ?? 0, - ec: actionData.water_actions?.ec ?? actionData.ec ?? 0, + let raw = payload.sensors || payload.sensor_data; + if (!raw) { + const action = parsePythonString(payload.action_taken); + if (action) { + raw = { + temp: action.atmospheric_actions?.air_temp ?? action.air_temp ?? 0, + ph: action.water_actions?.ph ?? action.ph ?? 0, + humidity: action.atmospheric_actions?.humidity ?? action.humidity ?? 0, + ec: action.water_actions?.ec ?? action.ec ?? 0, }; } else { - rawSensors = {}; + raw = {}; } } - - // Safely extract and format prioritizing known variations of the keys return { - temp: formatNumber(rawSensors.temp ?? rawSensors.air_temp ?? 0), - ph: formatNumber(rawSensors.pH ?? rawSensors.ph ?? 7.0), - humidity: formatNumber(rawSensors.humidity ?? 0), - ec: formatNumber(rawSensors.EC ?? rawSensors.ec ?? 0), + temp: formatNumber(raw.temp ?? 0), + ph: formatNumber(raw.pH ?? 7.0), + humidity: formatNumber(raw.humidity ?? 0), + ec: formatNumber(raw.EC ?? 0), }; }; @@ -62,32 +53,23 @@ export const calculateMaturity = (seq) => { export const formatOutcome = (outcome) => { if (!outcome || typeof outcome !== "string") return "Monitoring..."; - const parts = outcome.split("|").map((p) => p.trim()); - let tags = []; - let notes = ""; - + let tags = [], + notes = ""; parts.forEach((part) => { if (part.startsWith("condition_assessed")) { - const val = part.replace("condition_assessed", "").trim(); - if (val) tags.push(`Condition: ${val}`); + const v = part.replace("condition_assessed", "").trim(); + if (v) tags.push(`Condition: ${v}`); } else if (part.startsWith("health_score:")) { - const val = part.replace("health_score:", "").trim(); - if (val) tags.push(`Health Score: ${val}`); + const v = part.replace("health_score:", "").trim(); + if (v) tags.push(`Health: ${v}`); } else if (part.startsWith("notes:")) { notes = part.replace("notes:", "").trim(); } else if (part) { tags.push(part); } }); - - if (tags.length === 0 && !notes) { - return outcome; - } - - const tagsStr = tags.join(" • "); - if (tagsStr && notes) { - return `${tagsStr} - ${notes}`; - } - return tagsStr || notes; + if (!tags.length && !notes) return outcome; + const t = tags.join(" · "); + return t && notes ? `${t} — ${notes}` : t || notes; }; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js @@ -1,15 +1,28 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: [ - "./src/**/*.{js,jsx,ts,tsx}", - ], + content: ["./src/**/*.{js,jsx,ts,tsx}"], theme: { extend: { + fontFamily: { + sans: ["Syne", "sans-serif"], + mono: ["DM Mono", "monospace"], + serif: ["Instrument Serif", "serif"], + }, + colors: { + bg: "#0c1a0e", + surface: "#1a2b1c", + border: "#2a3f2c", + green: "#4ade80", + amber: "#f59e0b", + red: "#f87171", + blue: "#60a5fa", + }, animation: { - 'bounce-slow': 'bounce 3s infinite', - 'bounce-slower': 'bounce 4s infinite', - } + "fade-up": "fadeUp 0.5s ease forwards", + "fade-in": "fadeIn 0.3s ease forwards", + "spin-slow": "spin 20s linear infinite", + }, }, }, plugins: [], -} -\ No newline at end of file +};