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