demeter

Autonomous Hydroponic Intelligence
commit 392940139192e3502a0576d7bdb70132c6b5009d
parent 7a56edd48c44589dc03d9b7947c02b569ff855e2
Author: maydayv7 <maydayv7@gmail.com>
Date:   Tue, 10 Mar 2026 21:03:16 +0530

Unify data fetch in frontend

Diffstat:
D.cph/.E_The_Robotic_Rush.cpp_f1e7c1bd924933d8a52191ebb47b442e.prob | 2--
Mbackend/server/functions.py | 208++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mfrontend/src/pages/AgentControl.jsx | 670++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mfrontend/src/pages/CropDetails.jsx | 411+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mfrontend/src/pages/Dashboard.jsx | 153++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mfrontend/src/pages/LandingPage.jsx | 111+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Afrontend/src/utils/dataUtils.js | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 1027 insertions(+), 589 deletions(-)

diff --git a/.cph/.E_The_Robotic_Rush.cpp_f1e7c1bd924933d8a52191ebb47b442e.prob b/.cph/.E_The_Robotic_Rush.cpp_f1e7c1bd924933d8a52191ebb47b442e.prob @@ -1 +0,0 @@ -{"name":"E. The Robotic Rush","group":"Codeforces - Codeforces Round 1074 (Div. 4)","url":"https://codeforces.com/problemset/problem/2185/E","interactive":false,"memoryLimit":256,"timeLimit":3000,"tests":[{"id":1770804198119,"input":"3\n2 1 3\n0 1\n2\nLRR\n2 3 3\n2 4\n1 3 5\nLRL\n3 2 3\n1 3 7\n9 6\nRRL\n","output":"2 2 1\n0 0 0\n3 2 2\n"}],"testType":"single","input":{"type":"stdin"},"output":{"type":"stdout"},"languages":{"java":{"mainClass":"Main","taskClass":"ETheRoboticRush"}},"batch":{"id":"e003d38e-d869-4634-bd53-072a040dab87","size":1},"srcPath":"d:\\projects\\Demeter\\Demeter\\E_The_Robotic_Rush.cpp"} -\ No newline at end of file diff --git a/backend/server/functions.py b/backend/server/functions.py @@ -7,7 +7,7 @@ from fastapi import UploadFile from groq import Groq from qdrant_client.http import models from datetime import datetime -import re +import re from langchain_core.messages import SystemMessage, HumanMessage # --- AGENT IMPORTS --- @@ -16,7 +16,7 @@ from agent.sub_agents.Supervisor import SupervisorAgent from agent.sub_agents.atmospheric_agent import AtmosphericAgent from agent.sub_agents.water_agent import WaterAgent from agent.sub_agents.judge_agent import JudgeAgent -from agent.sub_agents.Explainer import ExplainerAgent +from agent.sub_agents.Explainer import ExplainerAgent from Qdrant.Store import store_fmu, COLLECTION_NAME from Qdrant.Client import client @@ -27,45 +27,55 @@ print("🌱 Initializing Demeter Cognitive Stack....") researcher = ResearcherAgent() atmos_agent = AtmosphericAgent() water_agent = WaterAgent() -supervisor = SupervisorAgent(researcher_agent=researcher) +supervisor = SupervisorAgent(researcher_agent=researcher) judge = JudgeAgent() -explainer = ExplainerAgent(supervisor.model) +explainer = ExplainerAgent(supervisor.model) print("✅ Agents Ready.") # --- HELPER FUNCTIONS --- + def get_next_sequence_number(crop_id: str) -> int: try: count_result = client.count( collection_name=COLLECTION_NAME, count_filter=models.Filter( - must=[models.FieldCondition(key="crop_id", match=models.MatchValue(value=crop_id))] - ) + must=[ + models.FieldCondition( + key="crop_id", match=models.MatchValue(value=crop_id) + ) + ] + ), ) return count_result.count + 1 except Exception as e: print(f"⚠️ Could not calculate sequence: {e}") return 1 + def filter_numeric_sensors(raw_data: dict) -> dict: """ Extracts only floating-point sensor values. """ clean = {} valid_keys = ["ph", "ec", "temp", "humidity", "co2", "light", "tds", "do", "orp"] - + for k, v in raw_data.items(): if any(valid in k.lower() for valid in valid_keys): try: clean[k] = float(v) except (ValueError, TypeError): - pass + pass return clean + # --- CORE ENDPOINTS --- -async def process_ingest(file: UploadFile, sensors_str: str, metadata_str: str, builder): + +async def process_ingest( + file: UploadFile, sensors_str: str, metadata_str: str, builder +): """ Handles file saving, FMU creation, and storage logic. """ @@ -77,52 +87,55 @@ async def process_ingest(file: UploadFile, sensors_str: str, metadata_str: str, raw_sensor_data = json.loads(sensors_str) meta_data = json.loads(metadata_str) abs_image_path = os.path.abspath(temp_filename) - + clean_sensors = filter_numeric_sensors(raw_sensor_data) # 1. Identity Logic target_crop = meta_data.get("crop", "Unknown") target_crop_id = meta_data.get("crop_id") or raw_sensor_data.get("crop_id") if not target_crop_id: - target_crop_id = f"Batch_{target_crop}_{datetime.now().strftime('%Y%m')}" - + target_crop_id = f"Batch_{target_crop}_{datetime.now().strftime('%Y%m')}" + seq_num = get_next_sequence_number(target_crop_id) print(f"📥 Ingesting {target_crop_id} | Snapshot #{seq_num}") # 2. Metadata Injection - meta_data.update({ - "crop_id": target_crop_id, - "sequence_number": seq_num, - "sensor_data": clean_sensors, - "action_taken": meta_data.get("action_taken", "PENDING_ACTION"), - "outcome": meta_data.get("outcome", "PENDING_OBSERVATION"), - }) + meta_data.update( + { + "crop_id": target_crop_id, + "sequence_number": seq_num, + "sensor_data": clean_sensors, + "action_taken": meta_data.get("action_taken", "PENDING_ACTION"), + "outcome": meta_data.get("outcome", "PENDING_OBSERVATION"), + } + ) # 3. Store fmu = builder.create_fmu(abs_image_path, clean_sensors, meta_data) store_fmu(fmu) - + return {"status": "success", "fmu_id": fmu.id} finally: if os.path.exists(temp_filename): os.remove(temp_filename) + async def process_search(file: UploadFile, sensors_str: str, builder): """ SIMPLIFIED AGENT LOOP: Atmos + Water + Supervisor ONLY. Updated for Web: Base64 Images + Metadata Consistency. """ temp_filename = f"temp_search_{file.filename}" - + try: # --- 1. SETUP: File & Base64 --- file_content = await file.read() - + # Save to disk (Required for FMU Builder) with open(temp_filename, "wb") as buffer: buffer.write(file_content) - + # Encode to Base64 (Required for Agents) image_b64 = base64.b64encode(file_content).decode("utf-8") abs_image_path = os.path.abspath(temp_filename) @@ -130,20 +143,22 @@ async def process_search(file: UploadFile, sensors_str: str, builder): # --- 2. DATA: Parse Sensors --- raw_sensor_data = json.loads(sensors_str) clean_sensors = filter_numeric_sensors(raw_sensor_data) - + target_crop = raw_sensor_data.get("crop", "Unknown") - target_crop_id = raw_sensor_data.get("crop_id", f"Batch_{target_crop}_{datetime.now().strftime('%Y%m')}") + target_crop_id = raw_sensor_data.get("crop_id") + if not target_crop_id: + target_crop_id = f"Batch_{target_crop}_{datetime.now().strftime('%Y%m')}" seq_num = get_next_sequence_number(target_crop_id) - + # Metadata construction (Using "sensors" key as requested) metadata = { - "crop": target_crop, + "crop": target_crop, "stage": raw_sensor_data.get("stage", "Unknown"), "crop_id": target_crop_id, "sequence_number": seq_num, - "sensors": clean_sensors, # <--- Correct key for web - "action_taken": "PENDING_DECISION", - "outcome": "PENDING" + "sensors": clean_sensors, # <--- Correct key for web + "action_taken": "PENDING_DECISION", + "outcome": "PENDING", } # Create and Store FMU (Snapshot of current state) @@ -155,39 +170,39 @@ async def process_search(file: UploadFile, sensors_str: str, builder): # Static strategy for web simplicity strat_instr = "Maintain optimal crop-specific parameters." strat_name = "STANDARD_MAINTENANCE" - action_idx = 0 + action_idx = 0 # --- 4. RESEARCH --- hits = client.query_points( collection_name=COLLECTION_NAME, query=query_fmu.vector, limit=3, - with_payload=True + with_payload=True, ) - - points_list = hits.points if hasattr(hits, 'points') else hits + + points_list = hits.points if hasattr(hits, "points") else hits research_query = f"optimal hydroponic conditions for {target_crop} in {metadata['stage']} stage" research_context = researcher.search(research_query) # --- 4. SUB-AGENTS (Atmos & Water) --- print("🧠 Specialists Planning...") - + # Pass empty strings for research/history, pass image_b64 for visuals atmos_plan = atmos_agent.reason( - sensors=clean_sensors, - research=research_context, - strategy=strat_instr, - history="No history provided.", - image_b64=image_b64 + sensors=clean_sensors, + research=research_context, + strategy=strat_instr, + history="No history provided.", + image_b64=image_b64, ) - + water_plan = water_agent.reason( - sensors=clean_sensors, - research=research_context, - strategy=strat_instr, - history="No history provided.", - image_b64=image_b64 + sensors=clean_sensors, + research=research_context, + strategy=strat_instr, + history="No history provided.", + image_b64=image_b64, ) print(f"🌬️ Atmospheric Plan:\n{atmos_plan}") @@ -195,13 +210,13 @@ async def process_search(file: UploadFile, sensors_str: str, builder): # --- 5. SUPERVISOR (Synthesis) --- print("👮 Supervisor Finalizing...") - + final_decision_json = supervisor.synthesize_plan( - atmos_plan, - water_plan, - query_fmu, - "No history context.", - strategy_info=(strat_name, strat_instr, action_idx) + atmos_plan, + water_plan, + query_fmu, + "No history context.", + strategy_info=(strat_name, strat_instr, action_idx), ) sub_agent_reports = {"Atmospheric": atmos_plan, "Water": water_plan} @@ -209,14 +224,20 @@ async def process_search(file: UploadFile, sensors_str: str, builder): current_fmu_context = { "metadata": metadata, "payload": {"sensors": clean_sensors}, - "vector": query_fmu.vector.tolist() if hasattr(query_fmu.vector, 'tolist') else query_fmu.vector + "vector": ( + query_fmu.vector.tolist() + if hasattr(query_fmu.vector, "tolist") + else query_fmu.vector + ), } - similar_fmus_formatted = [{"score": h.score, "payload": h.payload} for h in points_list] + similar_fmus_formatted = [ + {"score": h.score, "payload": h.payload} for h in points_list + ] explanation_log = explainer.explain( current_fmu=current_fmu_context, similar_fmus=similar_fmus_formatted, sub_agent_reports=sub_agent_reports, - final_decision=final_decision_json + final_decision=final_decision_json, ) # --- 6. DB UPDATE --- @@ -227,8 +248,8 @@ async def process_search(file: UploadFile, sensors_str: str, builder): payload={ "action_taken": str(final_decision_json), "outcome": "PENDING_OBSERVATION", - "strategic_intent": strat_name - } + "strategic_intent": strat_name, + }, ) return { @@ -236,7 +257,7 @@ async def process_search(file: UploadFile, sensors_str: str, builder): "new_fmu_id": query_fmu.id, "agent_decision": final_decision_json, "explanation": explanation_log, - "search_results": [{"id": p.id, "payload": p.payload} for p in points_list] + "search_results": [{"id": p.id, "payload": p.payload} for p in points_list], } except Exception as e: @@ -252,6 +273,7 @@ async def process_search(file: UploadFile, sensors_str: str, builder): except Exception: pass + def extract_json(text): """ Robustly extracts the first valid JSON object from a text string. @@ -261,23 +283,24 @@ def extract_json(text): match = re.search(r"```json\s*(\{.*?\})\s*```", text, re.DOTALL) if match: return json.loads(match.group(1)) - + # 2. Try finding content inside plain ``` ... ``` match = re.search(r"```\s*(\{.*?\})\s*```", text, re.DOTALL) if match: return json.loads(match.group(1)) - + # 3. Fallback: Find the first outermost { ... } match = re.search(r"(\{.*\})", text, re.DOTALL) if match: return json.loads(match.group(1)) - + except Exception: pass return {} + async def process_text_query(text: str): - + try: # 🟢 1. STRICT SYSTEM PROMPT system_prompt = """ @@ -299,18 +322,20 @@ async def process_text_query(text: str): """ print(f"🗣️ User Query: {text}") - - response = supervisor.model.invoke([ - SystemMessage(content=system_prompt), - HumanMessage(content=text) - ]) + + response = supervisor.model.invoke( + [SystemMessage(content=system_prompt), HumanMessage(content=text)] + ) # 🟢 2. ROBUST PARSING filter_logic = extract_json(response.content) - + if not filter_logic: print(f"⚠️ Failed to parse JSON from: {response.content}") - return {"status": "error", "message": "Could not understand query structure."} + return { + "status": "error", + "message": "Could not understand query structure.", + } print(f"⚙️ Parsed Logic: {filter_logic}") @@ -319,8 +344,7 @@ async def process_text_query(text: str): for item in filter_logic.get("must", []): conditions.append( models.FieldCondition( - key=item["key"], - match=models.MatchValue(value=item["match"]) + key=item["key"], match=models.MatchValue(value=item["match"]) ) ) @@ -331,58 +355,58 @@ async def process_text_query(text: str): collection_name=COLLECTION_NAME, scroll_filter=scroll_filter, limit=10, - with_payload=True + with_payload=True, ) else: # If no conditions, return the latest 10 items results = client.scroll( - collection_name=COLLECTION_NAME, - limit=10, - with_payload=True + collection_name=COLLECTION_NAME, limit=10, with_payload=True ) - + points = results[0] - + return { "status": "success", - "results": [{"id": p.id, "payload": p.payload} for p in points] + "results": [{"id": p.id, "payload": p.payload} for p in points], } except Exception as e: print(f"❌ Query Error: {e}") return {"status": "error", "message": str(e)} + groq_client = Groq(api_key=os.environ.get("GROQ_API_KEY")) - + + async def process_audio_search(file: UploadFile): """ 1. Transcribe Audio (Whisper-Large-V3) -> Text 2. Run Text Search (via existing process_text_query) """ temp_filename = f"temp_audio_{file.filename}" - + # Save audio temporarily try: with open(temp_filename, "wb") as buffer: shutil.copyfileobj(file.file, buffer) print("🎙️ Transcribing audio (Multilingual)...") - + # Open file in binary read mode with open(temp_filename, "rb") as audio_file: transcription = groq_client.audio.transcriptions.create( file=audio_file, - model="whisper-large-v3", # Multilingual model + model="whisper-large-v3", # Multilingual model response_format="json", - prompt="The audio may contain English or Hindi technical terms about farming." + prompt="The audio may contain English or Hindi technical terms about farming.", ) - + detected_text = transcription.text print(f"📝 Heard: '{detected_text}'") - + # This ensures we get the same RAG/Qdrant logic as text queries response_data = await process_text_query(detected_text) - + # Inject the transcription so the UI can show what was heard response_data["transcription"] = detected_text @@ -419,13 +443,12 @@ async def parse_natural_language_query(query_text: str): try: # instead of raw .chat.completions.create - response = supervisor.model.invoke([ - SystemMessage(content=system_prompt), - HumanMessage(content=query_text) - ]) - + response = supervisor.model.invoke( + [SystemMessage(content=system_prompt), HumanMessage(content=query_text)] + ) + return extract_json(response.content) except Exception as e: print(f"❌ Query Parse Error: {e}") - return {"must": []} -\ No newline at end of file + return {"must": []} diff --git a/frontend/src/pages/AgentControl.jsx b/frontend/src/pages/AgentControl.jsx @@ -1,18 +1,36 @@ import React, { useRef, useState } from "react"; import { Link } from "react-router-dom"; -import { - Upload, Save, Activity, Droplets, Thermometer, Wind, Search, - Sprout, Calendar, ArrowLeft, Leaf, Database, Mic, Square, Zap, Fan, FlaskConical, Waves, Brain +import { + Upload, + Save, + Activity, + Droplets, + Thermometer, + Wind, + Search, + Sprout, + Calendar, + ArrowLeft, + Leaf, + Database, + Mic, + Square, + Zap, + Fan, + FlaskConical, + Waves, + Brain, } from "lucide-react"; import { agentService } from "../api/agentApi"; +import { extractSensors } from "../utils/dataUtils"; export default function AgentControl() { const [file, setFile] = useState(null); const [preview, setPreview] = useState(null); - + const [loadingIngest, setLoadingIngest] = useState(false); const [loadingSearch, setLoadingSearch] = useState(false); - + const [searchResults, setSearchResults] = useState([]); const [textQuery, setTextQuery] = useState(""); @@ -22,10 +40,10 @@ export default function AgentControl() { const [isRecording, setIsRecording] = useState(false); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); - + // 🧠 State for the Supervisor's Output const [decision, setDecision] = useState(null); - const [strategy, setStrategy] = useState(""); + const [strategy, setStrategy] = useState(""); const [sensors, setSensors] = useState({ pH: "6.0", @@ -33,7 +51,7 @@ export default function AgentControl() { temp: "24.0", humidity: "60", crop: "Lettuce", - stage: "Vegetative" + stage: "Vegetative", }); // --- Handlers --- @@ -42,7 +60,7 @@ export default function AgentControl() { const selected = e.target.files[0]; setFile(selected); setPreview(URL.createObjectURL(selected)); - setSearchResults([]); + setSearchResults([]); setDecision(null); setStrategy(""); setExplanationText(""); @@ -75,13 +93,12 @@ export default function AgentControl() { 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 || []); + setSearchResults(response.search_results || []); } catch (error) { console.error(error); alert("❌ Search Failed."); @@ -93,16 +110,16 @@ export default function AgentControl() { const handleTextQuery = async () => { if (!textQuery) return; setLoadingSearch(true); - setSearchResults([]); + setSearchResults([]); try { const data = await agentService.queryText(textQuery); if (data.results) { // Map backend format to frontend expectation - const mappedResults = data.results.map(r => ({ + const mappedResults = data.results.map((r) => ({ id: r.id, - score: r.score || 1.0, - payload: r.payload + score: r.score || 1.0, + payload: r.payload, })); setSearchResults(mappedResults); if (mappedResults.length === 0) alert("No records found."); @@ -117,95 +134,129 @@ export default function AgentControl() { // --- 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" }; + 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" }; + 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 }); - mediaRecorderRef.current = new MediaRecorder(stream); - chunksRef.current = []; - mediaRecorderRef.current.ondataavailable = (e) => { - 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()); - }; - mediaRecorderRef.current.start(); - setIsRecording(true); - } catch (err) { - console.error("Mic Error:", err); - alert("Microphone access denied."); - } - }; - - const stopRecording = () => { - if (mediaRecorderRef.current && isRecording) { - mediaRecorderRef.current.stop(); - setIsRecording(false); - } - }; - - 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); - } - }; + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + mediaRecorderRef.current = new MediaRecorder(stream); + chunksRef.current = []; + mediaRecorderRef.current.ondataavailable = (e) => { + 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()); + }; + mediaRecorderRef.current.start(); + setIsRecording(true); + } catch (err) { + console.error("Mic Error:", err); + alert("Microphone access denied."); + } + }; - // 🟢 HELPER: Safe Number Formatting - const formatMetric = (val) => { - if (val === undefined || val === null) return "-"; - const num = parseFloat(val); - return isNaN(num) ? val : num.toFixed(2); - }; + const stopRecording = () => { + if (mediaRecorderRef.current && isRecording) { + mediaRecorderRef.current.stop(); + setIsRecording(false); + } + }; + + 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"> + <Link + to="/" + className="flex items-center space-x-2 hover:opacity-80 transition" + > <div className="bg-emerald-500 p-1.5 rounded-lg text-white"> - <Leaf size={20} fill="currentColor" /> + <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"> + <Link + to="/dashboard" + className="hover:text-emerald-600 transition-colors" + > Dashboard </Link> </div> @@ -213,44 +264,57 @@ export default function AgentControl() { </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> - <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"> - <ArrowLeft size={18} /> Back Home - </Link> + <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> + <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" + > + <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} + <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" /> + <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> + <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> )} @@ -259,102 +323,183 @@ export default function AgentControl() { {/* 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" - > - {isRecording ? <Square className="w-5 h-5" /> : <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> + {/* 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" + > + {isRecording ? ( + <Square className="w-5 h-5" /> + ) : ( + <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> - {/* 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`}>{field.label}</label> - <div className="relative"> - <div className={`absolute left-3 top-2.5 w-8 h-8 rounded-lg ${field.bg} flex items-center justify-center z-10`}> - <field.icon className={`w-4 h-4 ${field.color}`} /> - </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 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`} + > + {field.label} + </label> + <div className="relative"> + <div + className={`absolute left-3 top-2.5 w-8 h-8 rounded-lg ${field.bg} flex items-center justify-center z-10`} + > + <field.icon className={`w-4 h-4 ${field.color}`} /> + </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> - {/* Action Buttons */} - <div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-100"> - <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" - > - {loadingIngest ? <Activity className="animate-spin w-5 h-5" /> : <><Save className="w-5 h-5 text-gray-400 group-hover:text-emerald-500 transition-colors" /> <span>Store Memory</span></>} - </button> + {/* Action Buttons */} + <div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-100"> + <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" + > + {loadingIngest ? ( + <Activity className="animate-spin w-5 h-5" /> + ) : ( + <> + <Save className="w-5 h-5 text-gray-400 group-hover:text-emerald-500 transition-colors" />{" "} + <span>Store Memory</span> + </> + )} + </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" - > - {loadingSearch ? <Activity className="animate-spin w-5 h-5" /> : <><Search className="w-5 h-5" /> <span>Reason & Solve</span></>} - </button> - </div> + <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" + > + {loadingSearch ? ( + <Activity className="animate-spin w-5 h-5" /> + ) : ( + <> + <Search className="w-5 h-5" /> <span>Reason & Solve</span> + </> + )} + </button> </div> + </div> </div> </div> @@ -364,24 +509,28 @@ export default function AgentControl() { {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}/></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} /> + </div> 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> + </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> </div> - - <button + + <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" > - <Search size={12}/> View Logic Trace + <Search size={12} /> View Logic Trace </button> </div> @@ -389,30 +538,39 @@ export default function AgentControl() { <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 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"> - <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> + const props = getActionCardProps(key, value); + return ( + <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" + > + <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> + </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..."} - </div> - </div> + <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..."} + </div> + </div> )} </div> </div> @@ -433,16 +591,18 @@ export default function AgentControl() { <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {searchResults.map((res) => { - // 🟢 FIX: Handle both new 'sensors' key and legacy 'sensor_data' key - const sensors = res.payload.sensors || res.payload.sensor_data || {}; - + 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"> + <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"> @@ -457,32 +617,48 @@ export default function AgentControl() { {/* 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</div> + <div className="flex items-center gap-2 font-medium"> + <Calendar className="w-4 h-4 text-gray-400" /> Date + </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'} + {res.payload.timestamp + ? new Date( + res.payload.timestamp, + ).toLocaleDateString() + : "N/A"} </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> - {/* 🟢 FIX: Use sensors.pH and format number */} - <div className="text-emerald-800 font-mono font-bold text-lg">{formatMetric(sensors.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> - {/* 🟢 FIX: Use sensors.EC and format number */} - <div className="text-yellow-800 font-mono font-bold text-lg">{formatMetric(sensors.EC)}</div> - </div> + <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> + <div className="text-emerald-800 font-mono font-bold text-lg"> + {sensors.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> + <div className="text-yellow-800 font-mono font-bold text-lg"> + {sensors.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-2"> - <span className="font-bold text-gray-400 uppercase text-[10px] block mb-1">Outcome Note:</span> - {/* Simple cleanup of outcome text */} - {res.payload.outcome.replace("condition_assessed", "").replace("|", " • ")} - </div> + <div className="mt-2 text-xs bg-gray-50 p-2 rounded border border-gray-100 text-gray-600 line-clamp-2"> + <span className="font-bold text-gray-400 uppercase text-[10px] block mb-1"> + Outcome Note: + </span> + {/* Simple cleanup of outcome text */} + {res.payload.outcome + .replace("condition_assessed", "") + .replace("|", " • ")} + </div> )} </div> </div> diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx @@ -1,65 +1,33 @@ -import React, { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { fetchCropDetails } from '../api/farmApi'; -import { - ArrowLeft, Thermometer, Droplet, Sun, FlaskConical, Sparkles -} from 'lucide-react'; -import { - LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer -} from 'recharts'; - -// --- HELPER 1: Parse Python-style Dict Strings --- -const parsePythonString = (str) => { - if (!str) return null; - if (typeof str === 'object') return str; - - try { - return JSON.parse(str); - } catch (e) { - try { - // Fix Python single quotes and Booleans - const fixedStr = 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 null; - } - } -}; - -// --- HELPER 2: Extract Sensor Data Safely --- -const extractSensors = (payload) => { - if (!payload) return { temp: 0, ph: 0, lux: 0, humidity: 0 }; - - // 1. Check for standard "sensors" object - if (payload.sensors && payload.sensors.ph) { - return payload.sensors; - } - - // 2. If missing, look inside "action_taken" - const actionData = parsePythonString(payload.action_taken); - - if (actionData) { - // Handle nested structures like 'atmospheric_actions' or flat structures - return { - temp: actionData.atmospheric_actions?.air_temp || actionData.air_temp || 0, - ph: actionData.water_actions?.ph || actionData.ph || 0, - lux: actionData.atmospheric_actions?.light_intensity || 0, - humidity: actionData.atmospheric_actions?.humidity || 0, - }; - } - - // 3. Fallback - return { temp: 0, ph: 0, lux: 0, humidity: 0 }; -}; +import React, { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { fetchCropDetails } from "../api/farmApi"; +import { + ArrowLeft, + Thermometer, + Droplet, + Sun, + FlaskConical, + Sparkles, +} from "lucide-react"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { + extractSensors, + parsePythonString, + formatNumber, +} from "../utils/dataUtils"; const CropDetails = () => { const { cropId } = useParams(); const navigate = useNavigate(); - + const [history, setHistory] = useState([]); const [latest, setLatest] = useState(null); const [loading, setLoading] = useState(true); @@ -68,26 +36,28 @@ const CropDetails = () => { 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) + 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 processedHistory = sorted.map((item) => { const safePayload = item.payload || {}; const sensors = extractSensors(safePayload); return { ...item, - cleanSensors: sensors, - parsedAction: parsePythonString(safePayload.action_taken) + cleanSensors: sensors, + parsedAction: parsePythonString(safePayload.action_taken), }; }); setHistory(processedHistory); - setLatest(processedHistory[processedHistory.length - 1]); + setLatest(processedHistory[processedHistory.length - 1]); } else { setHistory([]); setLatest(null); @@ -101,65 +71,132 @@ const CropDetails = () => { getData(); }, [cropId]); - if (loading) return <div className="h-screen flex items-center justify-center text-gray-500">Loading Crop Data...</div>; - if (!latest) return <div className="h-screen flex items-center justify-center text-gray-500">Crop data not found.</div>; + if (loading) + return ( + <div className="h-screen flex items-center justify-center text-gray-500"> + Loading Crop Data... + </div> + ); + if (!latest) + return ( + <div className="h-screen flex items-center justify-center text-gray-500"> + Crop data not found. + </div> + ); const latestPayload = latest.payload || {}; // Safety: Ensure latestSensors is never undefined - const latestSensors = latest.cleanSensors || { temp: 0, ph: 0, lux: 0, humidity: 0 }; + const latestSensors = latest.cleanSensors || { + temp: 0, + ph: 0, + lux: 0, + humidity: 0, + }; // --- CHART DATA (With Safety Checks) --- - const chartData = history.map(h => ({ - time: h.payload?.timestamp ? new Date(h.payload.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '--:--', - // FIX: Use optional chaining (?.) and fallback (|| 0) - temp: h.cleanSensors?.temp || 0, - ph: h.cleanSensors?.ph || 0 + const chartData = history.map((h) => ({ + time: 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), })); // --- VITALS DATA --- const vitals = [ - { label: "Air Temp", value: `${latestSensors.temp || 0}°C`, status: "Optimal", icon: <Thermometer size={18} className="text-orange-500" />, color: "bg-orange-100" }, - { label: "Water pH", value: latestSensors.ph || "N/A", status: "Stable", icon: <FlaskConical size={18} className="text-purple-500" />, color: "bg-purple-100" }, - { label: "Humidity", value: `${latestSensors.humidity || 0}%`, status: "Optimal", icon: <Droplet size={18} className="text-blue-500" />, color: "bg-blue-100" }, - { label: "Light", value: `${latestSensors.lux || 0}`, status: "Optimal", icon: <Sun size={18} className="text-yellow-500" />, color: "bg-yellow-100" }, + { + 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", + }, + { + label: "Light", + value: `${formatNumber(latestSensors.lux)}`, + status: "Optimal", + icon: <Sun size={18} className="text-yellow-500" />, + color: "bg-yellow-100", + }, ]; 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"> + <button + onClick={() => navigate("/dashboard")} + className="p-2 hover:bg-gray-100 rounded-full transition" + > <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 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 + <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>{" "} + System Online </div> </header> {/* 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> + <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> </div> <div className="lg:col-span-2 bg-white rounded-2xl p-5 shadow-sm border border-gray-100 grid grid-cols-2 md:grid-cols-4 gap-4"> {vitals.map((v, i) => ( - <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"> - <div className={`w-12 h-12 rounded-full ${v.color} flex items-center justify-center mb-3`}>{v.icon}</div> + <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" + > + <div + className={`w-12 h-12 rounded-full ${v.color} flex items-center justify-center mb-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}</div> + <div className="text-2xl font-bold text-gray-900"> + {v.value} + </div> </div> ))} </div> @@ -167,47 +204,120 @@ const CropDetails = () => { {/* 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} /></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> {latestPayload.outcome || "Monitoring..."}</p> - - <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: {latestSensors.ph}</span> - <span className="px-2 py-1 bg-orange-50 text-orange-700 text-xs rounded border border-orange-100">Temp: {latestSensors.temp}°C</span> - </> - ) : ( - <span className="text-gray-400 italic">No automated actions active.</span> - )} - </div> - </div> - </div> - </div> + <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} /> + </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>{" "} + {latestPayload.outcome || "Monitoring..."} + </p> + + <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> + </div> + </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> + <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> @@ -215,29 +325,39 @@ const CropDetails = () => { <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) => ( - <div key={i} className="flex gap-4 items-start pb-4 border-b border-gray-50 last:border-0"> - <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 ${h.cleanSensors?.ph || 0} • Temp to ${h.cleanSensors?.temp || 0}°C` - : h.payload?.action_taken || "Routine Check"} - </div> - <div className="text-xs text-gray-500 mt-1"> - {h.payload?.outcome || "Monitoring"} - </div> - </div> - </div> - ))} + {[...history] + .reverse() + .slice(0, 10) + .map((h, i) => ( + <div + key={i} + className="flex gap-4 items-start pb-4 border-b border-gray-50 last:border-0" + > + <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"> + {h.payload?.outcome || "Monitoring"} + </div> + </div> + </div> + ))} </div> </div> - </main> </div> ); }; -export default CropDetails; -\ No newline at end of file +export default CropDetails; diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx @@ -1,9 +1,21 @@ -import React, { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { fetchDashboardData } from '../api/farmApi'; -import { - LayoutGrid, BarChart3, Bell, Settings, Droplet, Sun, Leaf, Search, Database, Thermometer, LogOut, Brain -} from 'lucide-react'; +import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { fetchDashboardData } from "../api/farmApi"; +import { extractSensors, calculateMaturity } from "../utils/dataUtils"; +import { + LayoutGrid, + BarChart3, + Bell, + Settings, + Droplet, + Sun, + Leaf, + Search, + Database, + Thermometer, + LogOut, + Brain, +} from "lucide-react"; const Dashboard = () => { const [crops, setCrops] = useState([]); @@ -13,32 +25,37 @@ const Dashboard = () => { useEffect(() => { const loadData = async () => { const data = await fetchDashboardData(); - + if (data && data.length > 0) { // --- DATA MAPPING --- - const formattedData = data.map(item => { + const formattedData = data.map((item) => { const p = item.payload || {}; - const sensors = p.sensors || {}; + 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), - status: p.outcome === 'CRITICAL' ? 'Critical' : (p.action_taken === 'PENDING_ACTION' ? 'Attention' : 'Healthy'), + image: getImageForCrop(p.crop), + status: + p.outcome === "CRITICAL" + ? "Critical" + : p.action_taken === "PENDING_ACTION" + ? "Attention" + : "Healthy", statusMsg: p.stage || "Growing", - maturity: calculateMaturity(p.sequence_number), - daysLeft: 30 - (p.sequence_number || 0), + maturity: calculateMaturity(p.sequence_number), + daysLeft: 30 - (p.sequence_number || 0), sensors: { - lux: `${sensors.lux || 0}k`, - temp: `${sensors.temp || 0}°C`, - ph: sensors.ph || 7.0 - } + lux: `${sensors.lux}k`, + temp: `${sensors.temp}°C`, + ph: sensors.ph, + }, }; }); setCrops(formattedData); } else { - setCrops([]); + setCrops([]); } setLoading(false); }; @@ -47,23 +64,27 @@ const Dashboard = () => { }, []); const getImageForCrop = (name) => { - if (!name) return "https://images.unsplash.com/photo-1618164436241-4473940d1f5c?q=80&w=2000"; + if (!name) + return "https://images.unsplash.com/photo-1618164436241-4473940d1f5c?q=80&w=2000"; 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"; - 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"; + 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"; + 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"; }; const calculateMaturity = (seq) => { - const val = (seq || 1) * 10; + const val = (seq || 1) * 10; return val > 100 ? 100 : val; }; 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> @@ -74,34 +95,39 @@ const Dashboard = () => { <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={<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">AF</div> + <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"> + AF + </div> <div className="flex-1"> <h4 className="text-sm font-bold text-gray-900">Alex Farmer</h4> <p className="text-xs text-gray-500">Head Agronomist</p> </div> - </div> + </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> - + <div className="flex items-center gap-4"> {/* NEW BUTTON FOR AGENT CONTROL */} - <button - onClick={() => navigate('/control')} + <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" > <Brain size={18} /> Agent Control @@ -110,19 +136,26 @@ const Dashboard = () => { <div className="w-px h-6 bg-gray-200 mx-2"></div> <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 + <span className="w-2 h-2 rounded-full bg-emerald-500"></span>{" "} + System online </div> </div> </header> <div className="flex-1 overflow-y-auto p-8"> {loading ? ( - <div className="flex h-full items-center justify-center text-gray-400">Loading Farm Data...</div> + <div className="flex h-full items-center justify-center text-gray-400"> + Loading Farm Data... + </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"> + <div + key={crop.id} + onClick={() => navigate(`/crop/${crop.id}`)} + className="cursor-pointer" + > <CropCard data={crop} /> </div> )) @@ -141,26 +174,34 @@ const Dashboard = () => { // 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'}`}> + <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'; + 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" /> + <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} + {data.statusMsg} </div> </div> <div className="space-y-4"> <div> - <h3 className="text-lg font-bold text-gray-900 leading-tight">{data.name}</h3> + <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> @@ -168,12 +209,29 @@ const CropCard = ({ data }) => { <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 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-3 gap-2 pt-2 border-t border-gray-50"> - <SensorItem icon={<Sun size={14} />} value={data.sensors.lux} label="Lux" /> - <SensorItem icon={<Thermometer size={14} />} value={data.sensors.temp} label="Temp" /> - <SensorItem icon={<Droplet size={14} />} value={data.sensors.ph} label="pH" /> + <SensorItem + icon={<Sun size={14} />} + value={data.sensors.lux} + label="Lux" + /> + <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> @@ -188,4 +246,4 @@ const SensorItem = ({ icon, value, label }) => ( </div> ); -export default Dashboard; -\ No newline at end of file +export default Dashboard; diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx @@ -1,17 +1,17 @@ -import React from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Leaf, - Database, - Moon, - Zap, - Droplet, - Cpu, - Building2, - Activity, - Rocket, - BrainCircuit -} from 'lucide-react'; +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { + Leaf, + Database, + Moon, + Zap, + Droplet, + Cpu, + Building2, + Activity, + Rocket, + BrainCircuit, +} from "lucide-react"; const LandingPage = () => { const navigate = useNavigate(); @@ -19,15 +19,14 @@ const LandingPage = () => { 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 + <div className="absolute inset-0 z-0" style={{ // CHANGE 2: Referencing the file directly from the public folder backgroundImage: "url('/background.png')", - backgroundSize: 'cover', - backgroundPosition: 'center', + backgroundSize: "cover", + backgroundPosition: "center", }} > {/* White Overlay Gradient */} @@ -43,8 +42,12 @@ const LandingPage = () => { <Leaf size={24} fill="currentColor" /> </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> + <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> </div> @@ -54,7 +57,7 @@ const LandingPage = () => { <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 @@ -69,10 +72,8 @@ const LandingPage = () => { {/* --- 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" /> @@ -92,35 +93,38 @@ const LandingPage = () => { {/* 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={<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={<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={<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." + <FeatureItem + icon={<Activity className="text-purple-600" size={18} />} + title="Data-Powered Insights" + desc="Instant anomaly detection via Vector Search." /> </div> {/* CTA Button */} <div className="pt-2"> - <button - onClick={() => navigate('/dashboard')} + <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" > - <Rocket size={20} className="group-hover:rotate-12 transition-transform" /> + <Rocket + size={20} + className="group-hover:rotate-12 transition-transform" + /> Try Demeter Now </button> </div> @@ -129,11 +133,10 @@ const LandingPage = () => { {/* 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> - + {/* 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" /> @@ -142,11 +145,15 @@ const LandingPage = () => { {/* 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> + <span className="text-sm font-mono text-emerald-800 font-bold"> + pH: Optimal + </span> </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> + <span className="text-sm font-mono text-blue-800 font-bold"> + Humidity: 65% + </span> </div> </div> </main> @@ -155,7 +162,6 @@ const LandingPage = () => { <footer className="relative z-10 flex-none w-full text-center py-4 text-gray-500 text-xs"> © 2024 Demeter AI Systems. Revolutionizing Hydroponics. </footer> - </div> ); }; @@ -163,14 +169,14 @@ const LandingPage = () => { // 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 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> + <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; -\ No newline at end of file +export default LandingPage; diff --git a/frontend/src/utils/dataUtils.js b/frontend/src/utils/dataUtils.js @@ -0,0 +1,61 @@ +export const formatNumber = (val) => { + if (val === undefined || val === null || isNaN(parseFloat(val))) return "0"; + return Math.round(parseFloat(val) * 100) / 100; +}; + +export const parsePythonString = (str) => { + if (!str) return null; + if (typeof str === 'object') return str; + + try { + return JSON.parse(str); + } catch (e) { + try { + // Fix Python single quotes and Booleans + const fixedStr = 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 null; + } + } +}; + +export const extractSensors = (payload) => { + if (!payload) return { temp: 0, ph: 0, lux: 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, + lux: actionData.atmospheric_actions?.light_intensity ?? actionData.light_intensity ?? 0, + humidity: actionData.atmospheric_actions?.humidity ?? actionData.humidity ?? 0, + ec: actionData.water_actions?.ec ?? actionData.ec ?? 0 + }; + } else { + rawSensors = {}; + } + } + + // 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), + lux: formatNumber(rawSensors.lux ?? rawSensors.light ?? rawSensors.light_intensity ?? 0), + humidity: formatNumber(rawSensors.humidity ?? 0), + ec: formatNumber(rawSensors.EC ?? rawSensors.ec ?? 0), + }; +}; + +export const calculateMaturity = (seq) => { + const val = (seq || 1) * 10; + return val > 100 ? 100 : val; +};