commit 392940139192e3502a0576d7bdb70132c6b5009d
parent 7a56edd48c44589dc03d9b7947c02b569ff855e2
Author: maydayv7 <maydayv7@gmail.com>
Date: Tue, 10 Mar 2026 21:03:16 +0530
Unify data fetch in frontend
Diffstat:
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;
+};