commit fd991066bf8c2045fa41aedce5b2400c9d662fa5
parent c7ef3924cce81c4dc65378794623a2f238b02279
Author: maydayv7 <maydayv7@gmail.com>
Date: Thu, 19 Mar 2026 23:24:08 +0530
Update Frontend Styling
Diffstat:
7 files changed, 2451 insertions(+), 1274 deletions(-)
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
@@ -9,7 +9,6 @@ import {
Brain,
ChevronLeft,
ChevronRight,
- Zap,
} from "lucide-react";
import { useFarmData } from "../hooks/useFarmData";
import { deriveCropStatus } from "../utils/dataUtils";
@@ -37,33 +36,64 @@ export default function Sidebar() {
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)",
+ borderRight: "1px solid var(--border)",
flexShrink: 0,
+ display: "flex",
+ flexDirection: "column",
+ position: "relative",
+ transition: "width 300ms ease",
+ overflow: "visible",
}}
>
{/* Logo */}
<div
- className="flex items-center gap-3 px-4 py-5 border-b"
- style={{ borderColor: "var(--border)" }}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 12,
+ padding: collapsed ? "20px 0" : "20px 16px",
+ justifyContent: collapsed ? "center" : "flex-start",
+ borderBottom: "1px solid var(--border)",
+ height: 64,
+ boxSizing: "border-box",
+ }}
>
<div
- className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
- style={{ background: "linear-gradient(135deg, #2d7a44, #4ade80)" }}
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: 10,
+ background: "linear-gradient(135deg, #2d7a44, #4ade80)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ }}
>
- <Leaf size={16} fill="white" color="white" />
+ <Leaf size={15} fill="white" color="white" />
</div>
{!collapsed && (
- <div className="overflow-hidden">
- <div className="font-bold text-sm" style={{ color: "var(--text)" }}>
+ <div style={{ overflow: "hidden" }}>
+ <div
+ style={{
+ fontWeight: 700,
+ fontSize: 14,
+ color: "var(--text)",
+ lineHeight: 1.2,
+ }}
+ >
Demeter
</div>
<div
- className="text-[10px] font-mono"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ letterSpacing: "0.1em",
+ }}
>
AGRI·AI·v2
</div>
@@ -71,53 +101,120 @@ export default function Sidebar() {
)}
</div>
- {/* Status bar */}
+ {/* Collapse toggle */}
+ <button
+ onClick={() => setCollapsed(!collapsed)}
+ style={{
+ position: "absolute",
+ top: 20,
+ right: -12,
+ width: 24,
+ height: 24,
+ borderRadius: "50%",
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ color: "var(--text-3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ cursor: "pointer",
+ zIndex: 10,
+ transition: "background 0.15s",
+ }}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.background = "var(--surface-2)")
+ }
+ onMouseLeave={(e) => (e.currentTarget.style.background = "var(--bg-3)")}
+ title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
+ >
+ {collapsed ? <ChevronRight size={11} /> : <ChevronLeft size={11} />}
+ </button>
+
+ {/* Status pill */}
{!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)" }}
+ <div style={{ padding: "12px 12px 0" }}>
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "6px 10px",
+ borderRadius: 8,
+ background: "rgba(74,222,128,0.08)",
+ border: "1px solid rgba(74,222,128,0.2)",
+ }}
>
- SYSTEM ONLINE
- </span>
+ <span
+ className="status-dot"
+ style={{
+ width: 6,
+ height: 6,
+ borderRadius: "50%",
+ background: "var(--green)",
+ flexShrink: 0,
+ }}
+ />
+ <span
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--green)",
+ }}
+ >
+ SYSTEM ONLINE
+ </span>
+ </div>
</div>
)}
{/* Nav */}
- <nav className="flex-1 px-2 py-4 space-y-1">
+ <nav
+ style={{
+ flex: 1,
+ padding: "12px 8px",
+ display: "flex",
+ flexDirection: "column",
+ gap: 2,
+ }}
+ >
{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"
+ title={collapsed ? label : undefined}
style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ padding: collapsed ? "10px 0" : "10px 12px",
+ justifyContent: collapsed ? "center" : "flex-start",
+ borderRadius: 10,
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",
+ textDecoration: "none",
+ position: "relative",
+ transition: "all 0.15s",
}}
>
- <Icon size={18} className="flex-shrink-0" />
+ <Icon size={17} style={{ flexShrink: 0 }} />
{!collapsed && (
- <span className="text-sm font-medium">{label}</span>
+ <span style={{ fontSize: 13, fontWeight: 500 }}>{label}</span>
)}
+ {/* Badge — unread count */}
{badge && !collapsed && (
<span
- className="ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded alert-pulse"
+ className="alert-pulse"
style={{
+ marginLeft: "auto",
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ padding: "1px 5px",
+ borderRadius: 4,
background: "rgba(248,113,113,0.2)",
color: "var(--red)",
}}
@@ -127,111 +224,134 @@ export default function Sidebar() {
)}
{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"
+ className="alert-pulse"
style={{
- background: "var(--surface-2)",
- border: "1px solid var(--border)",
- color: "var(--text)",
+ position: "absolute",
+ top: 4,
+ right: 4,
+ width: 7,
+ height: 7,
+ borderRadius: "50%",
+ background: "var(--red)",
}}
- >
- {label}
- </div>
+ />
)}
</Link>
);
})}
</nav>
- {/* System health */}
+ {/* Alert count summary */}
{!collapsed && (
- <div className="p-3 border-t" style={{ borderColor: "var(--border)" }}>
+ <div style={{ padding: "0 12px 12px" }}>
<div
- className="px-3 py-2 rounded-lg"
- style={{ background: "var(--surface)" }}
+ style={{
+ padding: "10px 12px",
+ borderRadius: 10,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ }}
>
- <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)" }}
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 6,
+ letterSpacing: "0.05em",
+ }}
>
- {alertCount > 0
- ? `${alertCount} crop(s) need attention`
- : "All clear"}
+ ALERT STATUS
</div>
+ {alertCount > 0 ? (
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
+ <span
+ className="alert-pulse"
+ style={{
+ width: 8,
+ height: 8,
+ borderRadius: "50%",
+ background: "var(--red)",
+ flexShrink: 0,
+ }}
+ />
+ <span
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--red)",
+ }}
+ >
+ {alertCount} crop{alertCount !== 1 ? "s" : ""} need attention
+ </span>
+ </div>
+ ) : (
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
+ <span
+ className="status-dot"
+ style={{
+ width: 8,
+ height: 8,
+ borderRadius: "50%",
+ background: "var(--green)",
+ flexShrink: 0,
+ }}
+ />
+ <span
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--green)",
+ }}
+ >
+ All clear
+ </span>
+ </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"
+ {/* User row */}
+ <div
style={{
- background: "var(--surface-2)",
- border: "1px solid var(--border)",
- color: "var(--text-3)",
+ padding: collapsed ? "12px 8px" : "12px 12px",
+ borderTop: "1px solid var(--border)",
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ justifyContent: collapsed ? "center" : "flex-start",
}}
>
- {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
+ style={{
+ width: 28,
+ height: 28,
+ borderRadius: "50%",
+ background: "var(--amber-dim)",
+ color: "var(--amber)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: 11,
+ fontWeight: 700,
+ flexShrink: 0,
+ }}
+ >
+ R
</div>
+ {!collapsed && (
+ <div>
+ <div
+ style={{ fontSize: 12, fontWeight: 600, color: "var(--text)" }}
+ >
+ Rajesh Rai
+ </div>
+ <div style={{ fontSize: 10, color: "var(--text-3)" }}>
+ Farm Owner
+ </div>
+ </div>
+ )}
</div>
</aside>
);
diff --git a/frontend/src/pages/AgentControl.jsx b/frontend/src/pages/AgentControl.jsx
@@ -247,16 +247,28 @@ export default function AgentControl() {
return (
<div
- className="flex h-screen overflow-hidden"
- style={{ background: "var(--bg)" }}
+ style={{
+ display: "flex",
+ height: "100vh",
+ overflow: "hidden",
+ 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"
+ className="animate-fade-in"
style={{
+ position: "fixed",
+ top: 16,
+ right: 16,
+ zIndex: 50,
+ padding: "10px 16px",
+ borderRadius: 12,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
background:
toast.type === "error"
? "rgba(248,113,113,0.15)"
@@ -269,84 +281,153 @@ export default function AgentControl() {
</div>
)}
- <main className="flex-1 flex flex-col overflow-hidden">
+ <main
+ style={{
+ flex: 1,
+ display: "flex",
+ flexDirection: "column",
+ 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)" }}
+ style={{
+ flexShrink: 0,
+ padding: "0 24px",
+ height: 64,
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-2)",
+ display: "flex",
+ alignItems: "center",
+ gap: 12,
+ }}
>
<div
- className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{
+ width: 32,
+ height: 32,
+ borderRadius: 8,
background: "rgba(74,222,128,0.1)",
border: "1px solid rgba(74,222,128,0.2)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
}}
>
<Brain size={15} style={{ color: "var(--green)" }} />
</div>
<div>
<h1
- className="font-bold text-base"
- style={{ color: "var(--text)" }}
+ style={{
+ fontWeight: 700,
+ fontSize: 15,
+ color: "var(--text)",
+ margin: 0,
+ }}
>
Agent Control
</h1>
<p
- className="text-[11px] font-mono"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ margin: 0,
+ }}
>
Ingest memories · Query the Supervisor · Run analysis
</p>
</div>
</header>
- <div className="flex-1 overflow-y-auto p-6">
+ <div
+ style={{
+ flex: 1,
+ overflowY: "auto",
+ padding: 24,
+ display: "flex",
+ flexDirection: "column",
+ gap: 20,
+ }}
+ >
{/* Search bar */}
<div
- className="flex gap-2 mb-6 p-2 rounded-xl"
style={{
+ display: "flex",
+ gap: 8,
+ padding: 8,
+ borderRadius: 12,
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<button
onClick={isRecording ? stopRecording : startRecording}
- className="p-2 rounded-lg transition-all"
style={{
+ width: 34,
+ height: 34,
+ borderRadius: 8,
+ flexShrink: 0,
+ cursor: "pointer",
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)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
}}
>
- {isRecording ? <Square size={14} /> : <Mic size={14} />}
+ {isRecording ? <Square size={13} /> : <Mic size={13} />}
</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)" }}
+ style={{
+ flex: 1,
+ background: "transparent",
+ border: "none",
+ outline: "none",
+ fontSize: 13,
+ fontFamily: "DM Mono, monospace",
+ 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" }}
+ style={{
+ padding: "6px 16px",
+ borderRadius: 8,
+ fontSize: 13,
+ fontWeight: 600,
+ background: "var(--green)",
+ color: "#0c1a0e",
+ border: "none",
+ cursor: "pointer",
+ }}
>
Ask
</button>
</div>
{/* Main grid */}
- <div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
+ <div
+ style={{ display: "grid", gridTemplateColumns: "2fr 3fr", gap: 20 }}
+ >
{/* Image upload */}
- <div className="lg:col-span-2">
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label
- className="relative block rounded-2xl overflow-hidden cursor-pointer"
style={{
- height: 320,
+ position: "relative",
+ display: "block",
+ borderRadius: 16,
+ overflow: "hidden",
+ cursor: "pointer",
+ height: 300,
background: "var(--surface)",
border: "2px dashed var(--border)",
}}
@@ -354,84 +435,142 @@ export default function AgentControl() {
<input
type="file"
onChange={handleFile}
- className="absolute inset-0 opacity-0 cursor-pointer z-10"
+ style={{
+ position: "absolute",
+ inset: 0,
+ opacity: 0,
+ cursor: "pointer",
+ zIndex: 10,
+ }}
/>
{preview ? (
- <img
- src={preview}
- alt="preview"
- className="w-full h-full object-cover"
- />
+ <>
+ <img
+ src={preview}
+ alt="preview"
+ style={{
+ width: "100%",
+ height: "100%",
+ objectFit: "cover",
+ }}
+ />
+ <div
+ style={{
+ position: "absolute",
+ inset: 0,
+ background:
+ "linear-gradient(to top, rgba(12,26,14,0.6) 0%, transparent 60%)",
+ }}
+ />
+ </>
) : (
- <div className="flex flex-col items-center justify-center h-full gap-3 p-6">
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ height: "100%",
+ gap: 12,
+ padding: 24,
+ }}
+ >
<div
- className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{
+ width: 48,
+ height: 48,
+ borderRadius: 16,
background: "var(--bg-3)",
border: "1px solid var(--border)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
}}
>
- <Upload size={22} style={{ color: "var(--text-3)" }} />
+ <Upload size={20} style={{ color: "var(--text-3)" }} />
</div>
- <div className="text-center">
+ <div style={{ textAlign: "center" }}>
<div
- className="font-semibold text-sm"
- style={{ color: "var(--text-2)" }}
+ style={{
+ fontWeight: 600,
+ fontSize: 13,
+ color: "var(--text-2)",
+ }}
>
Drop crop image
</div>
<div
- className="text-xs mt-1"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 11,
+ marginTop: 4,
+ color: "var(--text-3)",
+ }}
>
PNG, JPG up to 10MB
</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-3 mt-3">
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "1fr 1fr",
+ gap: 10,
+ }}
+ >
<button
onClick={handleIngest}
disabled={loadingIngest || loadingSearch}
- className="py-3 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all"
style={{
+ padding: "10px 0",
+ borderRadius: 12,
+ fontSize: 13,
+ fontWeight: 600,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 6,
+ cursor: "pointer",
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-2)",
+ opacity: loadingIngest || loadingSearch ? 0.6 : 1,
}}
>
{loadingIngest ? (
- <Activity size={14} className="animate-spin" />
+ <Activity size={13} className="animate-spin" />
) : (
<>
- <Save size={14} /> Store
+ <Save size={13} /> Store
</>
)}
</button>
<button
onClick={handleSearch}
disabled={loadingIngest || loadingSearch}
- className="py-3 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all"
- style={{ background: "var(--green)", color: "#0c1a0e" }}
+ style={{
+ padding: "10px 0",
+ borderRadius: 12,
+ fontSize: 13,
+ fontWeight: 600,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 6,
+ cursor: "pointer",
+ background: "var(--green)",
+ border: "none",
+ color: "#0c1a0e",
+ opacity: loadingIngest || loadingSearch ? 0.6 : 1,
+ }}
>
{loadingSearch ? (
- <Activity size={14} className="animate-spin" />
+ <Activity size={13} className="animate-spin" />
) : (
<>
- <Brain size={14} /> Analyze
+ <Brain size={13} /> Analyze
</>
)}
</button>
@@ -440,19 +579,30 @@ export default function AgentControl() {
{/* Sensor inputs */}
<div
- className="lg:col-span-3 rounded-2xl p-5"
style={{
+ borderRadius: 16,
+ padding: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<div
- className="text-[10px] font-mono mb-4"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 16,
+ }}
>
// SENSOR PARAMETERS
</div>
- <div className="grid grid-cols-2 gap-4">
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "1fr 1fr",
+ gap: 14,
+ }}
+ >
{INPUT_FIELDS.map(
({
label,
@@ -465,17 +615,30 @@ export default function AgentControl() {
}) => (
<div key={name}>
<label
- className="text-[10px] font-mono mb-1 block"
- style={{ color }}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ display: "block",
+ marginBottom: 5,
+ 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>
+ <div style={{ position: "relative" }}>
+ <Icon
+ size={12}
+ style={{
+ position: "absolute",
+ left: 10,
+ top: "50%",
+ transform: "translateY(-50%)",
+ color,
+ zIndex: 1,
+ }}
+ />
{type === "select" ? (
- <div className="relative">
+ <>
<select
value={sensors[name]}
onChange={(e) =>
@@ -484,11 +647,21 @@ export default function AgentControl() {
[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={{
+ width: "100%",
+ appearance: "none",
+ paddingLeft: 30,
+ paddingRight: 28,
+ paddingTop: 8,
+ paddingBottom: 8,
+ borderRadius: 8,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
background: "var(--bg-3)",
border: "1px solid var(--border)",
color: "var(--text)",
+ outline: "none",
+ cursor: "pointer",
}}
>
{opts.map((o) => (
@@ -498,11 +671,17 @@ export default function AgentControl() {
))}
</select>
<ChevronDown
- size={11}
- className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"
- style={{ color: "var(--text-3)" }}
+ size={10}
+ style={{
+ position: "absolute",
+ right: 10,
+ top: "50%",
+ transform: "translateY(-50%)",
+ pointerEvents: "none",
+ color: "var(--text-3)",
+ }}
/>
- </div>
+ </>
) : (
<input
value={sensors[name]}
@@ -512,11 +691,20 @@ export default function AgentControl() {
type={type}
placeholder={placeholder || ""}
step={type === "number" ? "0.1" : undefined}
- className="w-full pl-8 pr-3 py-2.5 rounded-lg text-sm font-mono outline-none"
style={{
+ width: "100%",
+ paddingLeft: 30,
+ paddingRight: 10,
+ paddingTop: 8,
+ paddingBottom: 8,
+ borderRadius: 8,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
background: "var(--bg-3)",
border: "1px solid var(--border)",
color: "var(--text)",
+ outline: "none",
+ boxSizing: "border-box",
}}
/>
)}
@@ -531,32 +719,47 @@ export default function AgentControl() {
{/* Decision output */}
{decision && (
<div
- className="mt-6 rounded-2xl overflow-hidden animate-fade-up"
+ className="animate-fade-up"
style={{
+ borderRadius: 16,
+ overflow: "hidden",
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)",
+ padding: 16,
+ borderBottom: "1px solid var(--border)",
background: "rgba(74,222,128,0.05)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
}}
>
- <div className="flex items-center gap-2">
- <Brain size={16} style={{ color: "var(--green)" }} />
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
+ <Brain size={15} style={{ color: "var(--green)" }} />
<span
- className="font-semibold text-sm"
- style={{ color: "var(--text)" }}
+ style={{
+ fontWeight: 600,
+ fontSize: 13,
+ color: "var(--text)",
+ }}
>
Supervisor Command
</span>
</div>
<button
onClick={() => setShowExplanation(!showExplanation)}
- className="flex items-center gap-1.5 text-[11px] font-mono px-2.5 py-1.5 rounded-lg transition-colors"
style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ padding: "5px 10px",
+ borderRadius: 8,
+ cursor: "pointer",
background: "var(--surface-2)",
border: "1px solid var(--border)",
color: "var(--text-3)",
@@ -566,7 +769,14 @@ export default function AgentControl() {
</button>
</div>
- <div className="p-5 grid grid-cols-3 md:grid-cols-5 gap-3">
+ <div
+ style={{
+ padding: 20,
+ display: "grid",
+ gridTemplateColumns: "repeat(5, 1fr)",
+ gap: 12,
+ }}
+ >
{Object.entries(decision).map(([key, value]) => {
const meta = ACTION_MAP[key] || {
label: key,
@@ -578,33 +788,54 @@ export default function AgentControl() {
return (
<div
key={key}
- className="rounded-xl p-4 text-center"
style={{
+ borderRadius: 12,
+ padding: 14,
+ textAlign: "center",
background: "var(--bg-3)",
border: "1px solid var(--border)",
}}
>
<div
- className="w-8 h-8 rounded-lg flex items-center justify-center mx-auto mb-2"
- style={{ background: `${meta.color}15` }}
+ style={{
+ width: 30,
+ height: 30,
+ borderRadius: 8,
+ background: `${meta.color}15`,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ margin: "0 auto 8px",
+ }}
>
- <Icon size={14} style={{ color: meta.color }} />
+ <Icon size={13} style={{ color: meta.color }} />
</div>
<div
- className="font-bold font-mono text-xl"
- style={{ color: meta.color }}
+ style={{
+ fontWeight: 700,
+ fontFamily: "DM Mono, monospace",
+ fontSize: 20,
+ color: meta.color,
+ }}
>
{value}
</div>
<div
- className="text-[9px] font-mono mt-0.5"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ marginTop: 2,
+ color: "var(--text-3)",
+ }}
>
{meta.unit}
</div>
<div
- className="text-[10px] mt-1"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ marginTop: 4,
+ color: "var(--text-3)",
+ }}
>
{meta.label}
</div>
@@ -615,21 +846,31 @@ export default function AgentControl() {
{showExplanation && explanationText && (
<div
- className="border-t p-5"
style={{
- borderColor: "var(--border)",
+ borderTop: "1px solid var(--border)",
+ padding: 20,
background: "var(--bg-3)",
}}
>
<div
- className="text-[10px] font-mono mb-2"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 8,
+ }}
>
// SUPERVISOR REASONING
</div>
<pre
- className="text-xs font-mono leading-relaxed whitespace-pre-wrap"
- style={{ color: "var(--text-2)" }}
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ lineHeight: 1.7,
+ whiteSpace: "pre-wrap",
+ color: "var(--text-2)",
+ margin: 0,
+ }}
>
{explanationText}
</pre>
@@ -640,41 +881,78 @@ export default function AgentControl() {
{/* 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)" }} />
+ <div>
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ marginBottom: 14,
+ }}
+ >
+ <Database size={13} style={{ color: "var(--text-3)" }} />
<span
- className="text-[11px] font-mono"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ 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">
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(3, 1fr)",
+ gap: 14,
+ }}
+ >
{searchResults.map((res) => {
const s = extractSensors(res.payload);
return (
<div
key={res.id}
- className="rounded-xl p-4 card-hover"
+ className="card-hover"
style={{
+ borderRadius: 12,
+ padding: 14,
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
- <div className="flex items-center justify-between mb-3">
- <div className="flex items-center gap-2">
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ marginBottom: 10,
+ }}
+ >
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ }}
+ >
<Leaf size={13} style={{ color: "var(--green)" }} />
<span
- className="font-semibold text-sm"
- style={{ color: "var(--text)" }}
+ style={{
+ fontWeight: 600,
+ fontSize: 13,
+ color: "var(--text)",
+ }}
>
{res.payload.crop || "Unknown"}
</span>
</div>
<span
- className="text-[10px] font-mono px-2 py-0.5 rounded"
style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ padding: "2px 6px",
+ borderRadius: 4,
background: "rgba(74,222,128,0.1)",
color: "var(--green)",
}}
@@ -682,46 +960,55 @@ export default function AgentControl() {
{((res.score || 1) * 100).toFixed(0)}%
</span>
</div>
- <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="font-mono font-bold text-sm"
- style={{ color: "var(--green)" }}
- >
- {s.ph}
- </div>
- </div>
- <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
+ style={{
+ display: "grid",
+ gridTemplateColumns: "1fr 1fr",
+ gap: 8,
+ }}
+ >
+ {[
+ { label: "pH", value: s.ph, color: "var(--green)" },
+ { label: "EC", value: s.ec, color: "var(--amber)" },
+ ].map(({ label, value, color }) => (
<div
- className="font-mono font-bold text-sm"
- style={{ color: "var(--amber)" }}
+ key={label}
+ style={{
+ borderRadius: 8,
+ padding: "6px 10px",
+ textAlign: "center",
+ background: "var(--bg-3)",
+ }}
>
- {s.ec}
+ <div
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
+ >
+ {label}
+ </div>
+ <div
+ style={{
+ fontWeight: 700,
+ fontFamily: "DM Mono, monospace",
+ fontSize: 14,
+ color,
+ }}
+ >
+ {value}
+ </div>
</div>
- </div>
+ ))}
</div>
{res.payload.outcome && (
<div
- className="mt-2 text-[11px]"
- style={{ color: "var(--text-3)" }}
+ style={{
+ marginTop: 8,
+ fontSize: 11,
+ color: "var(--text-3)",
+ }}
>
{formatOutcome(res.payload.outcome)?.substring(0, 80)}
…
diff --git a/frontend/src/pages/Alerts.jsx b/frontend/src/pages/Alerts.jsx
@@ -9,277 +9,13 @@ import {
BellOff,
Clock,
RefreshCw,
+ SlidersHorizontal,
} from "lucide-react";
import { useFarmData } from "../hooks/useFarmData";
-import { extractSensors, formatOutcome } from "../utils/dataUtils";
+import { generateAlerts } 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,
- });
- }
- }
-
- // Deduplicate: limit to 2 for cleaner UI
- const seen = new Map();
- const deduped = [];
- for (const a of alerts) {
- const key = `${a.severity}|${a.title}|${a.crop}`;
- const count = seen.get(key) || 0;
- if (count < 2) {
- deduped.push(a);
- seen.set(key, count + 1);
- }
- }
-
- 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
-
+// Severity
const SEV = {
critical: {
icon: AlertTriangle,
@@ -313,38 +49,67 @@ const AGENT_COLORS = {
HISTORIAN: "var(--text-3)",
};
+// Alert Card
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"
+ className="card-hover"
style={{
+ borderRadius: 12,
+ padding: 16,
background: alert.ack ? "var(--surface)" : s.bg,
border: `1px solid ${alert.ack ? "var(--border)" : s.border}`,
- opacity: alert.ack ? 0.6 : 1,
+ opacity: alert.ack ? 0.55 : 1,
+ transition: "opacity 0.2s",
}}
>
- <div className="flex items-start gap-3">
+ <div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
+ {/* Icon */}
<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}` }}
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: 8,
+ background: s.bg,
+ border: `1px solid ${s.border}`,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ marginTop: 2,
+ }}
>
- <Icon size={15} style={{ color: s.text }} />
+ <Icon size={14} style={{ color: s.text }} />
</div>
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2 flex-wrap">
+ {/* Content */}
+ <div style={{ flex: 1, minWidth: 0 }}>
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ flexWrap: "wrap",
+ }}
+ >
<span
- className="font-semibold text-sm"
- style={{ color: alert.ack ? "var(--text-2)" : "var(--text)" }}
+ style={{
+ fontWeight: 600,
+ fontSize: 13,
+ 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={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ padding: "2px 6px",
+ borderRadius: 4,
background: s.bg,
color: s.text,
border: `1px solid ${s.border}`,
@@ -353,8 +118,11 @@ function AlertCard({ alert, onAck, onDismiss }) {
{s.label}
</span>
<span
- className="text-[10px] font-mono px-1.5 py-0.5 rounded"
style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ padding: "2px 6px",
+ borderRadius: 4,
background: "rgba(0,0,0,0.3)",
color: AGENT_COLORS[alert.agent] || "var(--text-3)",
}}
@@ -362,38 +130,60 @@ function AlertCard({ alert, onAck, onDismiss }) {
{alert.agent}
</span>
</div>
+
<p
- className="text-xs mt-1 leading-relaxed"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 12,
+ color: "var(--text-3)",
+ margin: "4px 0 8px",
+ lineHeight: 1.5,
+ }}
>
{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)" }}
+
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
+ <span
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
>
<Clock size={9} /> {alert.time}
- </div>
- <div
- className="text-[10px] font-mono"
- style={{ color: "var(--text-3)" }}
+ </span>
+ <span
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
>
Crop: {alert.crop}
- </div>
+ </span>
</div>
</div>
- <div className="flex items-center gap-1 flex-shrink-0">
+ {/* Actions */}
+ <div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
{!alert.ack && (
<button
onClick={() => onAck(alert.id)}
title="Acknowledge"
- className="p-1.5 rounded-lg transition-colors"
style={{
+ width: 28,
+ height: 28,
+ borderRadius: 8,
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ cursor: "pointer",
}}
>
<CheckCircle2 size={13} />
@@ -402,11 +192,17 @@ function AlertCard({ alert, onAck, onDismiss }) {
<button
onClick={() => onDismiss(alert.id)}
title="Dismiss"
- className="p-1.5 rounded-lg transition-colors"
style={{
+ width: 28,
+ height: 28,
+ borderRadius: 8,
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ cursor: "pointer",
}}
>
<X size={13} />
@@ -426,9 +222,7 @@ export default function Alerts() {
const [showAcked, setShowAcked] = useState(false);
useEffect(() => {
- if (!loading) {
- setAlerts(generateAlerts(history));
- }
+ if (!loading) setAlerts(generateAlerts(history));
}, [history, loading]);
const ack = (id) =>
@@ -457,62 +251,144 @@ export default function Alerts() {
[alerts, filter, showAcked],
);
+ // Filters
+ const FILTER_OPTIONS = [
+ { 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)" },
+ ];
+
return (
<div
- className="flex h-screen overflow-hidden"
- style={{ background: "var(--bg)" }}
+ style={{
+ display: "flex",
+ height: "100vh",
+ overflow: "hidden",
+ background: "var(--bg)",
+ }}
>
<Sidebar />
- <main className="flex-1 flex flex-col overflow-hidden">
+
+ <main
+ style={{
+ flex: 1,
+ display: "flex",
+ flexDirection: "column",
+ 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)" }}
+ style={{
+ flexShrink: 0,
+ padding: "0 24px",
+ height: 64,
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-2)",
+ display: "flex",
+ alignItems: "center",
+ gap: 16,
+ }}
>
<div>
- <h1 className="font-bold text-lg" style={{ color: "var(--text)" }}>
+ <h1
+ style={{
+ fontWeight: 700,
+ fontSize: 18,
+ color: "var(--text)",
+ margin: 0,
+ }}
+ >
Alerts
</h1>
- <p className="text-xs font-mono" style={{ color: "var(--text-3)" }}>
+ <p
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ margin: 0,
+ }}
+ >
{loading
? "Analyzing sensor history…"
: `${counts.total} unacknowledged · ${alerts.length} total`}
</p>
</div>
- <div className="ml-auto flex items-center gap-2">
+
+ <div
+ style={{
+ marginLeft: "auto",
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ }}
+ >
<button
onClick={refreshData}
- className="p-2 rounded-lg transition-colors"
+ title="Reload"
style={{
+ width: 34,
+ height: 34,
+ borderRadius: 8,
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ cursor: "pointer",
}}
- 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={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "6px 12px",
+ borderRadius: 8,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
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)",
+ cursor: "pointer",
}}
>
{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={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "6px 12px",
+ borderRadius: 8,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-3)",
+ cursor: "pointer",
}}
>
<CheckCircle2 size={12} /> Ack all
@@ -522,45 +398,52 @@ export default function Alerts() {
{/* 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)" }}
+ style={{
+ flexShrink: 0,
+ padding: "8px 24px",
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-2)",
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ overflowX: "auto",
+ }}
>
- {[
- { 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 }) => (
+ <SlidersHorizontal
+ size={14}
+ style={{ color: "var(--text-3)", flexShrink: 0 }}
+ />
+ {FILTER_OPTIONS.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={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "6px 12px",
+ borderRadius: 20,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ flexShrink: 0,
+ cursor: "pointer",
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)",
+ transition: "all 0.15s",
}}
>
{count > 0 && (
<span
- className="w-4 h-4 rounded-full flex items-center justify-center text-[9px]"
style={{
+ width: 18,
+ height: 18,
+ borderRadius: "50%",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: 9,
background: color ? `${color}30` : "var(--border)",
color: color || "var(--text-3)",
}}
@@ -574,27 +457,53 @@ export default function Alerts() {
</div>
{/* Alert list */}
- <div className="flex-1 overflow-y-auto p-6">
+ <div style={{ flex: 1, overflowY: "auto", padding: 24 }}>
{loading ? (
- <div className="space-y-3 max-w-2xl mx-auto">
+ <div
+ style={{
+ maxWidth: 640,
+ margin: "0 auto",
+ display: "flex",
+ flexDirection: "column",
+ gap: 12,
+ }}
+ >
{[1, 2, 3].map((i) => (
<div
key={i}
- className="h-20 rounded-xl shimmer"
- style={{ border: "1px solid var(--border)" }}
+ className="shimmer"
+ style={{
+ height: 80,
+ borderRadius: 12,
+ border: "1px solid var(--border)",
+ }}
/>
))}
</div>
) : filtered.length === 0 ? (
- <div className="flex flex-col items-center justify-center h-full gap-4">
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ height: "100%",
+ gap: 16,
+ }}
+ >
<div
- className="w-16 h-16 rounded-2xl flex items-center justify-center"
style={{
+ width: 56,
+ height: 56,
+ borderRadius: 16,
background: "var(--surface)",
border: "1px solid var(--border)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
}}
>
- <CheckCircle2 size={28} style={{ color: "var(--green)" }} />
+ <CheckCircle2 size={24} style={{ color: "var(--green)" }} />
</div>
<div style={{ color: "var(--text-2)" }}>
{alerts.length === 0
@@ -603,17 +512,31 @@ export default function Alerts() {
</div>
</div>
) : (
- <div className="space-y-3 max-w-2xl mx-auto">
+ <div
+ style={{
+ maxWidth: 640,
+ margin: "0 auto",
+ display: "flex",
+ flexDirection: "column",
+ gap: 24,
+ }}
+ >
{/* Unacked */}
{filtered.filter((a) => !a.ack).length > 0 && (
<div>
<div
- className="text-[10px] font-mono mb-3"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 12,
+ }}
>
UNACKNOWLEDGED · {filtered.filter((a) => !a.ack).length}
</div>
- <div className="space-y-2">
+ <div
+ style={{ display: "flex", flexDirection: "column", gap: 8 }}
+ >
{filtered
.filter((a) => !a.ack)
.map((a) => (
@@ -630,14 +553,20 @@ export default function Alerts() {
{/* Acked */}
{showAcked && filtered.filter((a) => a.ack).length > 0 && (
- <div className="mt-6">
+ <div>
<div
- className="text-[10px] font-mono mb-3"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 12,
+ }}
>
ACKNOWLEDGED · {filtered.filter((a) => a.ack).length}
</div>
- <div className="space-y-2">
+ <div
+ style={{ display: "flex", flexDirection: "column", gap: 8 }}
+ >
{filtered
.filter((a) => a.ack)
.map((a) => (
diff --git a/frontend/src/pages/Analytics.jsx b/frontend/src/pages/Analytics.jsx
@@ -17,196 +17,28 @@ import {
PolarAngleAxis,
} from "recharts";
import { useFarmData } from "../hooks/useFarmData";
-import { extractSensors } from "../utils/dataUtils";
+import {
+ extractSensors,
+ avg,
+ safePct,
+ bucketHistory,
+ dailyCropActivity,
+ buildRadar,
+ buildAgentStats,
+} 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) => new Date(p.payload?.timestamp || 0).getTime() >= 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),
- }));
-}
-
-// 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]++;
- }
- 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) =>
- !/fail|negative|critical/.test(
- (p.payload?.outcome || "").toLowerCase(),
- ),
- ).length /
- total) *
- 100,
- )
- : 0,
- })).filter((a) => a.decisions > 0);
-}
-
-// COMPONENTS
-
+// Shared Tooltip
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={{
+ padding: "8px 12px",
+ borderRadius: 8,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
background: "var(--surface-2)",
border: "1px solid var(--border)",
color: "var(--text)",
@@ -222,40 +54,67 @@ const CustomTooltip = ({ active, payload, label }) => {
);
};
+// Metric Card
function MetricCard({ label, value, unit, change, color, loading }) {
- const up = change > 0,
- flat = change === 0;
+ const up = change > 0;
+ const flat = change === 0;
return (
<div
- className="rounded-xl p-5 card-hover"
+ className="card-hover"
style={{
+ borderRadius: 12,
+ padding: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<div
- className="text-[11px] font-mono mb-3"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 10,
+ }}
>
{label}
</div>
{loading ? (
- <div className="h-8 w-24 rounded shimmer" />
+ <div
+ className="shimmer"
+ style={{ height: 32, width: 96, borderRadius: 6 }}
+ />
) : (
<div
- className="text-3xl font-bold font-mono"
- style={{ color: color || "var(--text)" }}
+ style={{
+ fontSize: 28,
+ fontWeight: 700,
+ fontFamily: "DM Mono, monospace",
+ color: color || "var(--text)",
+ }}
>
{value}
<span
- className="text-base font-normal ml-1"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 14,
+ fontWeight: 400,
+ marginLeft: 4,
+ color: "var(--text-3)",
+ }}
>
{unit}
</span>
</div>
)}
- <div className="flex items-center gap-1 mt-2 text-[11px] font-mono">
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+ marginTop: 8,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ }}
+ >
{flat ? (
<Minus size={11} style={{ color: "var(--text-3)" }} />
) : up ? (
@@ -277,13 +136,23 @@ function MetricCard({ label, value, unit, change, color, loading }) {
function SectionTitle({ children, sub }) {
return (
- <div className="mb-4">
- <div className="text-[10px] font-mono" style={{ color: "var(--text-3)" }}>
+ <div style={{ marginBottom: 16 }}>
+ <div
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
+ >
// {sub}
</div>
<h2
- className="font-bold text-base mt-0.5"
- style={{ color: "var(--text)" }}
+ style={{
+ fontWeight: 700,
+ fontSize: 15,
+ color: "var(--text)",
+ margin: "4px 0 0",
+ }}
>
{children}
</h2>
@@ -294,14 +163,23 @@ function SectionTitle({ children, sub }) {
function EmptyChart({ height = 180, message = "No data yet" }) {
return (
<div
- className="flex items-center justify-center rounded-lg"
style={{
height,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: 8,
background: "var(--bg-3)",
border: "1px dashed var(--border)",
}}
>
- <span className="text-xs font-mono" style={{ color: "var(--text-3)" }}>
+ <span
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
+ >
{message}
</span>
</div>
@@ -319,7 +197,7 @@ export default function Analytics() {
[allPoints, range],
);
- // Compute averages from latest snapshot
+ // Latest fleet-wide averages
const latestSensors = useMemo(() => {
if (!dashboard.length) return { ph: 0, ec: 0, temp: 0 };
const sensors = dashboard.map((d) => extractSensors(d.payload));
@@ -332,12 +210,11 @@ export default function Analytics() {
};
}, [dashboard]);
- // Compare first half vs second half of history to get "change"
+ // First-half vs second-half for % change
const prevSensors = useMemo(() => {
if (allPoints.length < 2) return latestSensors;
- const half = Math.floor(allPoints.length / 2);
const older = allPoints
- .slice(0, half)
+ .slice(0, Math.floor(allPoints.length / 2))
.map((p) => extractSensors(p.payload));
return {
ph: parseFloat(avg(older.map((s) => parseFloat(s.ph) || 0)).toFixed(2)),
@@ -352,7 +229,7 @@ export default function Analytics() {
const radarData = useMemo(() => buildRadar(allPoints), [allPoints]);
const agentStats = useMemo(() => buildAgentStats(allPoints), [allPoints]);
- // Export CSV of bucketed data
+ // CSV export
const handleExport = () => {
if (!buckets.length) return;
const header = "time,ph,ec,temp,humidity,entries";
@@ -368,37 +245,83 @@ export default function Analytics() {
return (
<div
- className="flex h-screen overflow-hidden"
- style={{ background: "var(--bg)" }}
+ style={{
+ display: "flex",
+ height: "100vh",
+ overflow: "hidden",
+ background: "var(--bg)",
+ }}
>
<Sidebar />
- <main className="flex-1 flex flex-col overflow-hidden">
+
+ <main
+ style={{
+ flex: 1,
+ display: "flex",
+ flexDirection: "column",
+ 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)" }}
+ style={{
+ flexShrink: 0,
+ padding: "0 24px",
+ height: 64,
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-2)",
+ display: "flex",
+ alignItems: "center",
+ gap: 16,
+ }}
>
<div>
- <h1 className="font-bold text-lg" style={{ color: "var(--text)" }}>
+ <h1
+ style={{
+ fontWeight: 700,
+ fontSize: 18,
+ color: "var(--text)",
+ margin: 0,
+ }}
+ >
Analytics
</h1>
- <p className="text-xs font-mono" style={{ color: "var(--text-3)" }}>
+ <p
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ margin: 0,
+ }}
+ >
{loading
? "Loading…"
: `${allPoints.length} data points across ${dashboard.length} crops`}
</p>
</div>
- <div className="ml-auto flex items-center gap-2">
+ <div
+ style={{
+ marginLeft: "auto",
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ }}
+ >
{["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={{
+ padding: "5px 12px",
+ borderRadius: 8,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ cursor: "pointer",
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)",
+ transition: "all 0.15s",
}}
>
{r}
@@ -406,11 +329,18 @@ export default function Analytics() {
))}
<button
onClick={handleExport}
- className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-mono"
style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "5px 12px",
+ borderRadius: 8,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-3)",
+ cursor: "pointer",
}}
>
<Download size={12} /> Export CSV
@@ -418,9 +348,25 @@ export default function Analytics() {
</div>
</header>
- <div className="flex-1 overflow-y-auto p-6 space-y-8">
+ {/* Scrollable content */}
+ <div
+ style={{
+ flex: 1,
+ overflowY: "auto",
+ padding: 24,
+ display: "flex",
+ flexDirection: "column",
+ gap: 28,
+ }}
+ >
{/* Metric cards */}
- <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(4, 1fr)",
+ gap: 12,
+ }}
+ >
<MetricCard
loading={loading}
label="AVG pH"
@@ -459,158 +405,104 @@ export default function Analytics() {
</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
+ style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}
+ >
+ {[
+ {
+ title: "pH Over Time",
+ key: "ph",
+ stroke: "var(--green)",
+ gradId: "phGradA",
+ gradColor: "#4ade80",
+ },
+ {
+ title: "EC Concentration",
+ key: "ec",
+ stroke: "var(--amber)",
+ gradId: "ecGradA",
+ gradColor: "#f59e0b",
+ },
+ ].map(({ title, key, stroke, gradId, gradColor }) => (
+ <div
+ key={key}
+ style={{
+ borderRadius: 12,
+ padding: 20,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ }}
+ >
+ <SectionTitle sub={`${range.toUpperCase()} TRACE`}>
+ {title}
+ </SectionTitle>
+ {buckets.length < 2 ? (
+ <EmptyChart message="Not enough data for this range" />
+ ) : (
+ <ResponsiveContainer width="100%" height={180}>
+ <AreaChart data={buckets}>
+ <defs>
+ <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
+ <stop
+ offset="0%"
+ stopColor={gradColor}
+ stopOpacity={0.3}
+ />
+ <stop
+ offset="100%"
+ stopColor={gradColor}
+ 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={key}
+ stroke={stroke}
+ fill={`url(#${gradId})`}
+ strokeWidth={2}
+ dot={false}
+ name={key.toUpperCase()}
+ />
+ </AreaChart>
+ </ResponsiveContainer>
+ )}
+ </div>
+ ))}
</div>
{/* Temp + Humidity */}
<div
- className="rounded-xl p-5"
style={{
+ borderRadius: 12,
+ padding: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
}}
@@ -619,10 +511,7 @@ export default function Analytics() {
Temperature & Humidity
</SectionTitle>
{buckets.length < 2 ? (
- <EmptyChart
- height={180}
- message="Not enough data for this range"
- />
+ <EmptyChart message="Not enough data for this range" />
) : (
<ResponsiveContainer width="100%" height={180}>
<LineChart data={buckets}>
@@ -689,11 +578,14 @@ export default function Analytics() {
)}
</div>
- {/* Activity bar + Radar */}
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ {/* Activity + Radar */}
+ <div
+ style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}
+ >
<div
- className="rounded-xl p-5"
style={{
+ borderRadius: 12,
+ padding: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
}}
@@ -744,8 +636,9 @@ export default function Analytics() {
</div>
<div
- className="rounded-xl p-5"
style={{
+ borderRadius: 12,
+ padding: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
}}
@@ -786,38 +679,58 @@ export default function Analytics() {
</div>
</div>
- {/* Crop breakdown table */}
+ {/* Crop summary table */}
<div
- className="rounded-xl overflow-hidden"
style={{
+ borderRadius: 12,
+ overflow: "hidden",
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<div
- className="p-5 border-b"
- style={{ borderColor: "var(--border)" }}
+ style={{ padding: 20, borderBottom: "1px solid var(--border)" }}
>
<SectionTitle sub="PER CROP">Latest Sensor Summary</SectionTitle>
</div>
{loading ? (
- <div className="p-8 flex justify-center">
+ <div
+ style={{
+ padding: 32,
+ display: "flex",
+ justifyContent: "center",
+ }}
+ >
<span
- className="text-xs font-mono"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ 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)" }}
+ style={{
+ padding: 32,
+ textAlign: "center",
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
>
No crops in database
</div>
) : (
- <table className="w-full text-sm">
+ <table
+ style={{
+ width: "100%",
+ borderCollapse: "collapse",
+ fontSize: 12,
+ }}
+ >
<thead>
<tr style={{ borderBottom: "1px solid var(--border)" }}>
{[
@@ -831,8 +744,14 @@ export default function Analytics() {
].map((h) => (
<th
key={h}
- className="px-5 py-3 text-left text-[10px] font-mono"
- style={{ color: "var(--text-3)" }}
+ style={{
+ padding: "10px 20px",
+ textAlign: "left",
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ fontWeight: 400,
+ }}
>
{h}
</th>
@@ -849,44 +768,72 @@ export default function Analytics() {
style={{ borderBottom: "1px solid var(--border)" }}
>
<td
- className="px-5 py-3 font-mono text-xs"
- style={{ color: "var(--text)" }}
+ style={{
+ padding: "10px 20px",
+ fontFamily: "DM Mono, monospace",
+ fontSize: 11,
+ color: "var(--text)",
+ }}
>
{p.crop_id || "—"}
</td>
<td
- className="px-5 py-3 font-mono text-xs"
- style={{ color: "var(--text-2)" }}
+ style={{
+ padding: "10px 20px",
+ fontFamily: "DM Mono, monospace",
+ fontSize: 11,
+ color: "var(--text-2)",
+ }}
>
{p.crop || "—"}
</td>
<td
- className="px-5 py-3 font-mono text-xs"
- style={{ color: "var(--text-2)" }}
+ style={{
+ padding: "10px 20px",
+ fontFamily: "DM Mono, monospace",
+ fontSize: 11,
+ color: "var(--text-2)",
+ }}
>
{p.stage || "—"}
</td>
<td
- className="px-5 py-3 font-mono text-xs"
- style={{ color: "var(--green)" }}
+ style={{
+ padding: "10px 20px",
+ fontFamily: "DM Mono, monospace",
+ fontSize: 11,
+ color: "var(--green)",
+ }}
>
{s.ph}
</td>
<td
- className="px-5 py-3 font-mono text-xs"
- style={{ color: "var(--amber)" }}
+ style={{
+ padding: "10px 20px",
+ fontFamily: "DM Mono, monospace",
+ fontSize: 11,
+ color: "var(--amber)",
+ }}
>
{s.ec}
</td>
<td
- className="px-5 py-3 font-mono text-xs"
- style={{ color: "var(--blue)" }}
+ style={{
+ padding: "10px 20px",
+ fontFamily: "DM Mono, monospace",
+ fontSize: 11,
+ color: "var(--blue)",
+ }}
>
{s.temp}°C
</td>
<td
- className="px-5 py-3 font-mono text-xs"
- style={{ color: "var(--text-2)" }}
+ style={{
+ padding: "10px 20px",
+ fontFamily: "DM Mono, monospace",
+ fontSize: 11,
+ color: "var(--text-2)",
+ }}
>
{p.sequence_number || 1}
</td>
@@ -898,24 +845,30 @@ export default function Analytics() {
)}
</div>
- {/* Agent activity derived from action_taken fields */}
+ {/* Agent activity */}
{agentStats.length > 0 && (
<div
- className="rounded-xl overflow-hidden"
style={{
+ borderRadius: 12,
+ overflow: "hidden",
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<div
- className="p-5 border-b"
- style={{ borderColor: "var(--border)" }}
+ style={{ padding: 20, borderBottom: "1px solid var(--border)" }}
>
<SectionTitle sub="DERIVED FROM STORED ACTIONS">
Agent Activity
</SectionTitle>
</div>
- <table className="w-full text-sm">
+ <table
+ style={{
+ width: "100%",
+ borderCollapse: "collapse",
+ fontSize: 12,
+ }}
+ >
<thead>
<tr style={{ borderBottom: "1px solid var(--border)" }}>
{[
@@ -926,8 +879,14 @@ export default function Analytics() {
].map((h) => (
<th
key={h}
- className="px-5 py-3 text-left text-[10px] font-mono"
- style={{ color: "var(--text-3)" }}
+ style={{
+ padding: "10px 20px",
+ textAlign: "left",
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ fontWeight: 400,
+ }}
>
{h}
</th>
@@ -941,26 +900,45 @@ export default function Analytics() {
style={{ borderBottom: "1px solid var(--border)" }}
>
<td
- className="px-5 py-3 font-mono text-xs"
- style={{ color: "var(--text)" }}
+ style={{
+ padding: "10px 20px",
+ fontFamily: "DM Mono, monospace",
+ fontSize: 11,
+ color: "var(--text)",
+ }}
>
{name}
</td>
<td
- className="px-5 py-3 font-mono text-xs"
- style={{ color: "var(--text-2)" }}
+ style={{
+ padding: "10px 20px",
+ fontFamily: "DM Mono, monospace",
+ fontSize: 11,
+ color: "var(--text-2)",
+ }}
>
{decisions}
</td>
- <td className="px-5 py-3">
- <div className="flex items-center gap-2">
+ <td style={{ padding: "10px 20px" }}>
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ }}
+ >
<div
- className="h-1.5 w-24 rounded-full"
- style={{ background: "var(--border)" }}
+ style={{
+ height: 6,
+ width: 96,
+ borderRadius: 3,
+ background: "var(--border)",
+ }}
>
<div
- className="h-full rounded-full"
style={{
+ height: "100%",
+ borderRadius: 3,
width: `${accuracy}%`,
background:
accuracy > 80
@@ -972,17 +950,23 @@ export default function Analytics() {
/>
</div>
<span
- className="font-mono text-xs"
- style={{ color: "var(--text-2)" }}
+ style={{
+ fontFamily: "DM Mono, monospace",
+ fontSize: 11,
+ color: "var(--text-2)",
+ }}
>
{accuracy}%
</span>
</div>
</td>
- <td className="px-5 py-3">
+ <td style={{ padding: "10px 20px" }}>
<span
- className="text-[10px] font-mono px-2 py-0.5 rounded-full"
style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ padding: "3px 8px",
+ borderRadius: 20,
background: "rgba(74,222,128,0.1)",
color: "var(--green)",
border: "1px solid rgba(74,222,128,0.2)",
diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx
@@ -8,7 +8,6 @@ import {
Wind,
Zap,
Activity,
- Clock,
} from "lucide-react";
import {
AreaChart,
@@ -33,8 +32,11 @@ 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={{
+ padding: "8px 12px",
+ borderRadius: 8,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
background: "var(--surface-2)",
border: "1px solid var(--border)",
color: "var(--text)",
@@ -53,31 +55,63 @@ const CustomTooltip = ({ active, payload, label }) => {
function StatBox({ icon: Icon, label, value, color, unit }) {
return (
<div
- className="rounded-xl p-4"
+ className="card-hover"
style={{
+ borderRadius: 12,
+ padding: 16,
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
- <div className="flex items-center gap-2 mb-3">
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ marginBottom: 10,
+ }}
+ >
<div
- className="w-7 h-7 rounded-lg flex items-center justify-center"
- style={{ background: `${color}15`, border: `1px solid ${color}30` }}
+ style={{
+ width: 28,
+ height: 28,
+ borderRadius: 8,
+ background: `${color}15`,
+ border: `1px solid ${color}30`,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
>
<Icon size={13} style={{ color }} />
</div>
<span
- className="text-[10px] font-mono uppercase"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ textTransform: "uppercase",
+ color: "var(--text-3)",
+ }}
>
{label}
</span>
</div>
- <div className="text-2xl font-bold font-mono" style={{ color }}>
+ <div
+ style={{
+ fontSize: 22,
+ fontWeight: 700,
+ fontFamily: "DM Mono, monospace",
+ color,
+ }}
+ >
{value}
<span
- className="text-sm font-normal ml-0.5"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 13,
+ fontWeight: 400,
+ marginLeft: 2,
+ color: "var(--text-3)",
+ }}
>
{unit}
</span>
@@ -86,6 +120,16 @@ function StatBox({ icon: Icon, label, value, color, unit }) {
);
}
+// Severity dot for logs
+function logDotColor(payload) {
+ const outcome = (payload?.outcome || "").toLowerCase();
+ if (/fail|critical|disease|error/.test(outcome)) return "var(--red)";
+ if (/deteriorat|negative|attention/.test(outcome)) return "var(--amber)";
+ return "var(--green)";
+}
+
+const TABS = ["overview", "sensors", "log"];
+
export default function CropDetails() {
const { cropId } = useParams();
const navigate = useNavigate();
@@ -102,14 +146,11 @@ export default function CropDetails() {
(a.payload?.sequence_number || 0) -
(b.payload?.sequence_number || 0),
);
- const processed = sorted.map((item) => {
- const p = item.payload || {};
- return {
- ...item,
- cleanSensors: extractSensors(p),
- parsedAction: parsePythonString(p.action_taken),
- };
- });
+ const processed = sorted.map((item) => ({
+ ...item,
+ cleanSensors: extractSensors(item.payload || {}),
+ parsedAction: parsePythonString((item.payload || {}).action_taken),
+ }));
setHistory(processed);
setLatest(processed[processed.length - 1]);
}
@@ -119,22 +160,46 @@ export default function CropDetails() {
if (loading)
return (
- <div className="flex h-screen" style={{ background: "var(--bg)" }}>
+ <div
+ style={{ display: "flex", height: "100vh", background: "var(--bg)" }}
+ >
<Sidebar />
- <div className="flex-1 flex items-center justify-center">
- <div className="text-xs font-mono" style={{ color: "var(--text-3)" }}>
+ <div
+ style={{
+ flex: 1,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <span
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
+ >
Loading crop data…
- </div>
+ </span>
</div>
</div>
);
if (!latest)
return (
- <div className="flex h-screen" style={{ background: "var(--bg)" }}>
+ <div
+ style={{ display: "flex", height: "100vh", background: "var(--bg)" }}
+ >
<Sidebar />
- <div className="flex-1 flex items-center justify-center">
- <div style={{ color: "var(--text-3)" }}>Crop not found</div>
+ <div
+ style={{
+ flex: 1,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <span style={{ color: "var(--text-3)" }}>Crop not found</span>
</div>
</div>
);
@@ -155,77 +220,127 @@ export default function CropDetails() {
humidity: formatNumber(h.cleanSensors?.humidity),
}));
- const TABS = ["overview", "sensors", "log"];
-
return (
<div
- className="flex h-screen overflow-hidden"
- style={{ background: "var(--bg)" }}
+ style={{
+ display: "flex",
+ height: "100vh",
+ overflow: "hidden",
+ background: "var(--bg)",
+ }}
>
<Sidebar />
- <main className="flex-1 flex flex-col overflow-hidden">
+
+ <main
+ style={{
+ flex: 1,
+ display: "flex",
+ flexDirection: "column",
+ 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)" }}
+ style={{
+ flexShrink: 0,
+ padding: "0 24px",
+ height: 64,
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-2)",
+ display: "flex",
+ alignItems: "center",
+ gap: 16,
+ }}
>
<button
onClick={() => navigate("/dashboard")}
- className="p-2 rounded-lg transition-colors"
style={{
+ width: 34,
+ height: 34,
+ borderRadius: 8,
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ cursor: "pointer",
}}
>
<ArrowLeft size={15} />
</button>
+
<div>
<h1
- className="font-bold text-base"
- style={{ color: "var(--text)" }}
+ style={{
+ fontWeight: 700,
+ fontSize: 15,
+ color: "var(--text)",
+ margin: 0,
+ }}
>
{p.crop || "Unknown"}{" "}
- <span style={{ color: "var(--text-3)" }}>
+ <span style={{ color: "var(--text-3)", fontWeight: 400 }}>
#{p.sequence_number || 0}
</span>
</h1>
<p
- className="text-[11px] font-mono"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ margin: 0,
+ }}
>
{cropId} · {p.stage}
</p>
</div>
- {/* Status */}
+ {/* Live badge */}
<div
- className="flex items-center gap-1.5 ml-4 px-3 py-1.5 rounded-full text-[11px] font-mono"
style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "4px 10px",
+ borderRadius: 20,
background: "rgba(74,222,128,0.1)",
border: "1px solid rgba(74,222,128,0.25)",
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
color: "var(--green)",
}}
>
<span
- className="status-dot w-1.5 h-1.5 rounded-full"
- style={{ background: "var(--green)" }}
- />{" "}
+ className="status-dot"
+ style={{
+ width: 6,
+ height: 6,
+ borderRadius: "50%",
+ background: "var(--green)",
+ }}
+ />
LIVE
</div>
{/* Tabs */}
- <div className="ml-auto flex items-center gap-1">
+ <div style={{ marginLeft: "auto", display: "flex", gap: 4 }}>
{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={{
+ padding: "5px 12px",
+ borderRadius: 8,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ textTransform: "capitalize",
+ cursor: "pointer",
background:
activeTab === tab ? "var(--surface-2)" : "transparent",
color: activeTab === tab ? "var(--text)" : "var(--text-3)",
border: `1px solid ${activeTab === tab ? "var(--border-bright)" : "transparent"}`,
+ transition: "all 0.15s",
}}
>
{tab}
@@ -234,11 +349,27 @@ export default function CropDetails() {
</div>
</header>
- <div className="flex-1 overflow-y-auto p-6 space-y-6">
+ {/* Content */}
+ <div
+ style={{
+ flex: 1,
+ overflowY: "auto",
+ padding: 24,
+ display: "flex",
+ flexDirection: "column",
+ gap: 20,
+ }}
+ >
+ {/* OVERVIEW */}
{activeTab === "overview" && (
<>
- {/* Stat grid */}
- <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(4, 1fr)",
+ gap: 12,
+ }}
+ >
<StatBox
icon={Thermometer}
label="Temperature"
@@ -269,24 +400,29 @@ export default function CropDetails() {
/>
</div>
- {/* Main chart */}
+ {/* pH chart */}
<div
- className="rounded-xl p-5"
style={{
+ borderRadius: 12,
+ padding: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<div
- className="text-[10px] font-mono mb-4"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 16,
+ }}
>
// HISTORICAL pH TRACE
</div>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData}>
<defs>
- <linearGradient id="phGrad2" x1="0" y1="0" x2="0" y2="1">
+ <linearGradient id="phGradCD" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="#4ade80"
@@ -329,7 +465,7 @@ export default function CropDetails() {
type="monotone"
dataKey="ph"
stroke="var(--green)"
- fill="url(#phGrad2)"
+ fill="url(#phGradCD)"
strokeWidth={2}
dot={false}
name="pH"
@@ -338,33 +474,49 @@ export default function CropDetails() {
</ResponsiveContainer>
</div>
- {/* AI Analysis */}
+ {/* AI analysis */}
<div
- className="rounded-xl p-5"
style={{
+ borderRadius: 12,
+ padding: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<div
- className="text-[10px] font-mono mb-3"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 12,
+ }}
>
// LATEST AI ANALYSIS
</div>
- <div className="flex items-start gap-3">
+ <div
+ style={{ display: "flex", alignItems: "flex-start", gap: 12 }}
+ >
<div
- className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
+ width: 32,
+ height: 32,
+ borderRadius: 8,
background: "rgba(74,222,128,0.1)",
border: "1px solid rgba(74,222,128,0.2)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
}}
>
<Zap size={14} style={{ color: "var(--green)" }} />
</div>
<div
- className="text-sm leading-relaxed"
- style={{ color: "var(--text-2)" }}
+ style={{
+ fontSize: 13,
+ lineHeight: 1.6,
+ color: "var(--text-2)",
+ }}
>
{formatOutcome(p.outcome) ||
"System monitoring active. No anomalies detected."}
@@ -372,34 +524,43 @@ export default function CropDetails() {
</div>
{p.action_taken && p.action_taken !== "PENDING_ACTION" && (
<div
- className="mt-3 p-3 rounded-lg font-mono text-xs"
style={{
+ marginTop: 12,
+ padding: 10,
+ borderRadius: 8,
+ fontFamily: "DM Mono, monospace",
+ fontSize: 11,
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)}...
+ {p.action_taken?.substring(0, 200)}…
</div>
)}
</div>
</>
)}
+ {/* SENSORS */}
{activeTab === "sensors" && (
- <div className="space-y-6">
- {/* Temp + Humidity */}
+ <>
<div
- className="rounded-xl p-5"
style={{
+ borderRadius: 12,
+ padding: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<div
- className="text-[10px] font-mono mb-4"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 16,
+ }}
>
// TEMP & HUMIDITY
</div>
@@ -450,24 +611,28 @@ export default function CropDetails() {
</ResponsiveContainer>
</div>
- {/* EC */}
<div
- className="rounded-xl p-5"
style={{
+ borderRadius: 12,
+ padding: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<div
- className="text-[10px] font-mono mb-4"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 16,
+ }}
>
// EC CONCENTRATION
</div>
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData}>
<defs>
- <linearGradient id="ecGrad2" x1="0" y1="0" x2="0" y2="1">
+ <linearGradient id="ecGradCD" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="#f59e0b"
@@ -509,7 +674,7 @@ export default function CropDetails() {
type="monotone"
dataKey="ec"
stroke="var(--amber)"
- fill="url(#ecGrad2)"
+ fill="url(#ecGradCD)"
strokeWidth={2}
dot={false}
name="EC dS/m"
@@ -517,82 +682,137 @@ export default function CropDetails() {
</AreaChart>
</ResponsiveContainer>
</div>
- </div>
+ </>
)}
+ {/* LOG */}
{activeTab === "log" && (
<div
- className="rounded-xl overflow-hidden"
style={{
+ borderRadius: 12,
+ overflow: "hidden",
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
+ {/* Header row */}
<div
- className="p-4 border-b"
- style={{ borderColor: "var(--border)" }}
+ style={{
+ padding: "12px 20px",
+ borderBottom: "1px solid var(--border)",
+ }}
>
<div
- className="text-[10px] font-mono"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
>
// EVENT LOG — {history.length} ENTRIES
</div>
</div>
- <div
- className="divide-y"
- style={{ borderColor: "var(--border)" }}
- >
+
+ {/* Log rows */}
+ <div>
{[...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={{
+ display: "flex",
+ alignItems: "center",
+ gap: 12,
+ padding: "10px 20px",
background:
i % 2 === 0
? "transparent"
- : "rgba(255,255,255,0.01)",
+ : "rgba(255,255,255,0.015)",
+ transition: "background 0.15s",
}}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.background =
+ "rgba(74,222,128,0.04)")
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.background =
+ i % 2 === 0
+ ? "transparent"
+ : "rgba(255,255,255,0.015)")
+ }
>
- <div
- className="flex items-center gap-1 text-[10px] font-mono flex-shrink-0 w-16"
- style={{ color: "var(--text-3)" }}
+ {/* Severity dot */}
+ <span
+ style={{
+ width: 7,
+ height: 7,
+ borderRadius: "50%",
+ background: logDotColor(h.payload),
+ flexShrink: 0,
+ }}
+ />
+
+ {/* Timestamp */}
+ <span
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ flexShrink: 0,
+ width: 50,
+ }}
>
- <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)" }}
+ </span>
+
+ {/* Seq # */}
+ <span
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ width: 34,
+ flexShrink: 0,
+ }}
>
#{h.payload?.sequence_number || i}
- </div>
+ </span>
+
+ {/* Sensor snapshot */}
+ <span
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-2)",
+ flexShrink: 0,
+ }}
+ >
+ pH {formatNumber(h.cleanSensors?.ph)} ·{" "}
+ {formatNumber(h.cleanSensors?.temp)}°C · EC{" "}
+ {formatNumber(h.cleanSensors?.ec)}
+ </span>
+
+ {/* Outcome / action */}
+ <span
+ style={{
+ fontSize: 11,
+ color: "var(--text-3)",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ }}
+ >
+ {formatOutcome(h.payload?.outcome) ||
+ h.payload?.action_taken ||
+ "Routine check"}
+ </span>
</div>
))}
</div>
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
@@ -50,18 +50,28 @@ function CropCard({ data, onClick }) {
return (
<div
onClick={onClick}
- className="rounded-2xl overflow-hidden cursor-pointer card-hover"
+ className="card-hover"
style={{
+ borderRadius: 16,
+ overflow: "hidden",
+ cursor: "pointer",
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
- {/* Image / Gradient header */}
+ {/* Image header */}
<div
- className="relative h-36 overflow-hidden"
- style={{ background: "var(--bg-3)" }}
+ style={{ position: "relative", height: 136, background: "var(--bg-3)" }}
>
- <div className="absolute inset-0 flex items-center justify-center">
+ <div
+ style={{
+ position: "absolute",
+ inset: 0,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
<Leaf
size={40}
style={{ color: "var(--border-bright)", opacity: 0.4 }}
@@ -71,24 +81,39 @@ function CropCard({ data, onClick }) {
<img
src={data.image}
alt={data.name}
- className="absolute inset-0 w-full h-full object-cover opacity-70"
+ style={{
+ position: "absolute",
+ inset: 0,
+ width: "100%",
+ height: "100%",
+ objectFit: "cover",
+ opacity: 0.7,
+ }}
onError={(e) => {
e.target.style.display = "none";
}}
/>
)}
- {/* Overlay */}
+ {/* Bottom fade */}
<div
- className="absolute inset-0"
style={{
+ position: "absolute",
+ inset: 0,
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={{
+ position: "absolute",
+ top: 10,
+ right: 10,
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ padding: "3px 8px",
+ borderRadius: 20,
background: st.bg,
color: st.text,
border: `1px solid ${st.border}`,
@@ -101,24 +126,44 @@ function CropCard({ data, onClick }) {
: "◆ "}
{data.status.toUpperCase()}
</div>
- {/* Seq number */}
+ {/* Seq badge */}
<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)" }}
+ style={{
+ position: "absolute",
+ top: 10,
+ left: 10,
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ padding: "2px 6px",
+ borderRadius: 4,
+ 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
+ style={{
+ padding: 14,
+ display: "flex",
+ flexDirection: "column",
+ gap: 10,
+ }}
+ >
+ {/* Name */}
<div>
- <div className="font-bold text-sm" style={{ color: "var(--text)" }}>
+ <div style={{ fontWeight: 700, fontSize: 13, color: "var(--text)" }}>
{data.name}
</div>
<div
- className="text-[11px] mt-0.5 font-mono"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 10,
+ marginTop: 2,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
>
{data.cropId} · {data.statusMsg}
</div>
@@ -127,20 +172,27 @@ function CropCard({ data, onClick }) {
{/* Maturity bar */}
<div>
<div
- className="flex justify-between text-[10px] font-mono mb-1"
- style={{ color: "var(--text-3)" }}
+ style={{
+ display: "flex",
+ justifyContent: "space-between",
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginBottom: 4,
+ }}
>
<span>Maturity</span>
<span style={{ color: "var(--green)" }}>{maturity}%</span>
</div>
<div
- className="h-1 rounded-full"
- style={{ background: "var(--border)" }}
+ style={{ height: 3, borderRadius: 2, background: "var(--border)" }}
>
<div
- className="h-full rounded-full progress-fill"
+ className="progress-fill"
style={{
width: `${maturity}%`,
+ height: "100%",
+ borderRadius: 2,
background:
maturity > 70
? "var(--green)"
@@ -152,22 +204,30 @@ function CropCard({ data, onClick }) {
</div>
</div>
- {/* Sensor row */}
- <div className="grid grid-cols-2 gap-2 pt-1">
- <div className="flex items-center gap-1.5">
+ {/* Sensor pair */}
+ <div
+ style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}
+ >
+ <div style={{ display: "flex", alignItems: "center", gap: 5 }}>
<Thermometer size={12} style={{ color: "var(--text-3)" }} />
<span
- className="text-xs font-mono"
- style={{ color: "var(--text-2)" }}
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-2)",
+ }}
>
{data.sensors.temp}°C
</span>
</div>
- <div className="flex items-center gap-1.5">
+ <div style={{ display: "flex", alignItems: "center", gap: 5 }}>
<Droplet size={12} style={{ color: "var(--text-3)" }} />
<span
- className="text-xs font-mono"
- style={{ color: "var(--text-2)" }}
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-2)",
+ }}
>
pH {data.sensors.ph}
</span>
@@ -176,12 +236,22 @@ function CropCard({ data, onClick }) {
{/* Footer */}
<div
- className="flex items-center justify-between pt-1 border-t"
- style={{ borderColor: "var(--border)" }}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingTop: 8,
+ borderTop: "1px solid var(--border)",
+ }}
>
<div
- className="flex items-center gap-1 text-[10px]"
- style={{ color: "var(--text-3)" }}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+ fontSize: 10,
+ color: "var(--text-3)",
+ }}
>
<Clock size={10} />
{data.daysLeft > 0 ? `${data.daysLeft}d left` : "Ready"}
@@ -203,6 +273,18 @@ export default function Dashboard() {
const [filterStatus, setFilterStatus] = useState("All");
const [showFilters, setShowFilters] = useState(false);
+ const getImg = (name) => {
+ if (!name) return null;
+ const n = name.toLowerCase();
+ if (n.includes("tomato"))
+ 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=400";
+ return "https://images.unsplash.com/photo-1622206151226-18ca2c9ab4a1?q=80&w=400";
+ };
+
useEffect(() => {
if (dashboard) {
setCrops(
@@ -228,34 +310,24 @@ export default function Dashboard() {
}
}, [dashboard]);
- const getImg = (name) => {
- if (!name) return null;
- const n = name.toLowerCase();
- if (n.includes("tomato"))
- 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=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 filtered = useMemo(
+ () =>
+ 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",
@@ -273,28 +345,68 @@ export default function Dashboard() {
return (
<div
- className="flex h-screen overflow-hidden"
- style={{ background: "var(--bg)" }}
+ style={{
+ display: "flex",
+ height: "100vh",
+ overflow: "hidden",
+ background: "var(--bg)",
+ }}
>
<Sidebar />
- <main className="flex-1 flex flex-col overflow-hidden">
- {/* Header */}
+ <main
+ style={{
+ flex: 1,
+ display: "flex",
+ flexDirection: "column",
+ overflow: "hidden",
+ }}
+ >
+ {/* ── Header — 64px, border aligns with sidebar ── */}
<header
- className="flex-shrink-0 px-6 py-4 border-b flex items-center gap-4"
- style={{ borderColor: "var(--border)", background: "var(--bg-2)" }}
+ style={{
+ flexShrink: 0,
+ padding: "0 24px",
+ height: 64,
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-2)",
+ display: "flex",
+ alignItems: "center",
+ gap: 16,
+ }}
>
<div>
- <h1 className="font-bold text-lg" style={{ color: "var(--text)" }}>
+ <h1
+ style={{
+ fontWeight: 700,
+ fontSize: 18,
+ color: "var(--text)",
+ margin: 0,
+ }}
+ >
Crops Overview
</h1>
- <p className="text-xs font-mono" style={{ color: "var(--text-3)" }}>
+ <p
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ margin: 0,
+ }}
+ >
{filtered.length} of {crops.length} crops shown
</p>
</div>
{/* Summary chips */}
- <div className="hidden md:flex items-center gap-2 ml-4">
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ marginLeft: 16,
+ }}
+ >
{[
{
label: "Healthy",
@@ -314,10 +426,16 @@ export default function Dashboard() {
].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={{
+ display: "flex",
+ alignItems: "center",
+ gap: 5,
+ padding: "3px 10px",
+ borderRadius: 20,
background: "var(--surface)",
border: "1px solid var(--border)",
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
color,
}}
>
@@ -327,51 +445,86 @@ export default function Dashboard() {
))}
</div>
- <div className="ml-auto flex items-center gap-2">
- <button
- onClick={refreshData}
- className="p-2 rounded-lg transition-colors"
- style={{
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color: "var(--text-3)",
- }}
- >
- <RefreshCw size={15} className={loading ? "animate-spin" : ""} />
- </button>
- </div>
+ <button
+ onClick={refreshData}
+ style={{
+ marginLeft: "auto",
+ width: 34,
+ height: 34,
+ borderRadius: 8,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: "var(--text-3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ cursor: "pointer",
+ }}
+ >
+ <RefreshCw size={15} className={loading ? "animate-spin" : ""} />
+ </button>
</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)" }}
+ style={{
+ flexShrink: 0,
+ padding: "8px 24px",
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-2)",
+ display: "flex",
+ alignItems: "center",
+ gap: 12,
+ }}
>
{/* Search */}
- <div className="relative flex-1 max-w-md">
+ <div style={{ position: "relative", flex: 1, maxWidth: 360 }}>
<Search
size={14}
- className="absolute left-3 top-1/2 -translate-y-1/2"
- style={{ color: "var(--text-3)" }}
+ style={{
+ position: "absolute",
+ left: 10,
+ top: "50%",
+ transform: "translateY(-50%)",
+ 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={{
+ width: "100%",
+ paddingLeft: 32,
+ paddingRight: search ? 28 : 12,
+ paddingTop: 7,
+ paddingBottom: 7,
+ borderRadius: 8,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text)",
+ outline: "none",
caretColor: "var(--green)",
}}
/>
{search && (
<button
onClick={() => setSearch("")}
- className="absolute right-3 top-1/2 -translate-y-1/2"
+ style={{
+ position: "absolute",
+ right: 8,
+ top: "50%",
+ transform: "translateY(-50%)",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: 0,
+ color: "var(--text-3)",
+ }}
>
- <X size={12} style={{ color: "var(--text-3)" }} />
+ <X size={12} />
</button>
)}
</div>
@@ -379,40 +532,59 @@ export default function Dashboard() {
{/* Filter toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
- className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-all"
style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "7px 12px",
+ borderRadius: 8,
+ fontSize: 12,
+ cursor: "pointer",
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)",
+ transition: "all 0.15s",
}}
>
- <SlidersHorizontal size={14} /> Filters
+ <SlidersHorizontal size={13} />
+ Filters
{activeFilters > 0 && (
<span
- className="px-1.5 py-0.5 rounded text-[10px] font-mono"
- style={{ background: "var(--green)", color: "#0c1a0e" }}
+ style={{
+ padding: "0 5px",
+ borderRadius: 4,
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ background: "var(--green)",
+ color: "#0c1a0e",
+ }}
>
{activeFilters}
</span>
)}
</button>
- {/* Quick category pills */}
- <div className="hidden lg:flex items-center gap-2">
+ {/* Quick stage pills */}
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{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={{
+ padding: "5px 12px",
+ borderRadius: 20,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ cursor: "pointer",
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)",
+ transition: "all 0.15s",
}}
>
{s}
@@ -424,8 +596,16 @@ export default function Dashboard() {
{/* 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)" }}
+ className="animate-fade-in"
+ style={{
+ flexShrink: 0,
+ padding: "8px 24px",
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-3)",
+ display: "flex",
+ alignItems: "center",
+ gap: 24,
+ }}
>
{[
{
@@ -447,22 +627,34 @@ export default function Dashboard() {
opts: STATUSES,
},
].map(({ label, value, set, opts }) => (
- <div key={label} className="flex items-center gap-2">
+ <div
+ key={label}
+ style={{ display: "flex", alignItems: "center", gap: 8 }}
+ >
<span
- className="text-[11px] font-mono"
- style={{ color: "var(--text-3)" }}
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
>
{label}
</span>
- <div className="relative">
+ <div style={{ position: "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={{
+ appearance: "none",
+ padding: "5px 24px 5px 10px",
+ borderRadius: 8,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-2)",
+ cursor: "pointer",
+ outline: "none",
}}
>
{opts.map((o) => (
@@ -473,8 +665,14 @@ export default function Dashboard() {
</select>
<ChevronDown
size={10}
- className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none"
- style={{ color: "var(--text-3)" }}
+ style={{
+ position: "absolute",
+ right: 8,
+ top: "50%",
+ transform: "translateY(-50%)",
+ pointerEvents: "none",
+ color: "var(--text-3)",
+ }}
/>
</div>
</div>
@@ -486,8 +684,15 @@ export default function Dashboard() {
setFilterStatus("All");
setSearch("");
}}
- className="ml-auto text-[11px] font-mono transition-colors"
- style={{ color: "var(--text-3)" }}
+ style={{
+ marginLeft: "auto",
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ color: "var(--text-3)",
+ }}
>
Clear all
</button>
@@ -495,21 +700,37 @@ export default function Dashboard() {
)}
{/* Crop grid */}
- <div className="flex-1 overflow-y-auto p-6">
+ <div style={{ flex: 1, overflowY: "auto", padding: 24 }}>
{loading ? (
- <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
+ gap: 16,
+ }}
+ >
{Array(8)
.fill(0)
.map((_, i) => (
<div
key={i}
- className="h-64 rounded-2xl shimmer"
- style={{ border: "1px solid var(--border)" }}
+ className="shimmer"
+ style={{
+ height: 260,
+ borderRadius: 16,
+ 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">
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
+ gap: 16,
+ }}
+ >
{filtered.map((crop) => (
<CropCard
key={crop.id}
@@ -519,15 +740,29 @@ export default function Dashboard() {
))}
</div>
) : (
- <div className="flex flex-col items-center justify-center h-full gap-4">
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ height: "100%",
+ gap: 16,
+ }}
+ >
<div
- className="w-16 h-16 rounded-2xl flex items-center justify-center"
style={{
+ width: 56,
+ height: 56,
+ borderRadius: 16,
background: "var(--surface)",
border: "1px solid var(--border)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
}}
>
- <Activity size={28} style={{ color: "var(--text-3)" }} />
+ <Activity size={24} style={{ color: "var(--text-3)" }} />
</div>
<div style={{ color: "var(--text-2)" }}>
No crops match your filters
@@ -539,11 +774,15 @@ export default function Dashboard() {
setFilterCrop("All");
setFilterStatus("All");
}}
- className="text-xs font-mono px-4 py-2 rounded-lg"
style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ padding: "6px 16px",
+ borderRadius: 8,
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-3)",
+ cursor: "pointer",
}}
>
Clear filters
diff --git a/frontend/src/utils/dataUtils.js b/frontend/src/utils/dataUtils.js
@@ -1,8 +1,11 @@
-export const formatNumber = (val) => {
+// Number formatting
+export const formatNumber = (val, decimals = 2) => {
if (val === undefined || val === null || isNaN(parseFloat(val))) return "0";
- return Math.round(parseFloat(val) * 100) / 100;
+ const factor = Math.pow(10, decimals);
+ return Math.round(parseFloat(val) * factor) / factor;
};
+// Python dict / JSON string parser
export const parsePythonString = (str) => {
if (!str) return null;
if (typeof str === "object") return str;
@@ -22,9 +25,12 @@ export const parsePythonString = (str) => {
}
};
+// Sensor extraction
export const extractSensors = (payload) => {
if (!payload) return { temp: 0, ph: 0, humidity: 0, ec: 0 };
+
let raw = payload.sensors || payload.sensor_data;
+
if (!raw) {
const action = parsePythonString(payload.action_taken);
if (action) {
@@ -38,6 +44,7 @@ export const extractSensors = (payload) => {
raw = {};
}
}
+
return {
temp: formatNumber(raw.temp ?? raw.Temp ?? 0),
ph: formatNumber(raw.ph ?? raw.pH ?? 7.0),
@@ -46,10 +53,10 @@ export const extractSensors = (payload) => {
};
};
-export const calculateMaturity = (seq) => {
- return Math.min((seq || 1) * 10, 100);
-};
+// Plant maturity (based on cycle count)
+export const calculateMaturity = (seq) => Math.min((seq || 1) * 10, 100);
+// Outcome string formatting
export const formatOutcome = (outcome) => {
if (!outcome || typeof outcome !== "string") return "Monitoring...";
@@ -57,8 +64,10 @@ export const formatOutcome = (outcome) => {
const cleanOutcome = outcome.split("| Reward:")[0].trim();
const parts = cleanOutcome.split("|").map((p) => p.trim());
- let tags = [],
- notes = "";
+
+ let tags = [];
+ let notes = "";
+
parts.forEach((part) => {
if (part.startsWith("condition_assessed")) {
const v = part.replace("condition_assessed", "").trim();
@@ -72,6 +81,7 @@ export const formatOutcome = (outcome) => {
tags.push(part);
}
});
+
if (!tags.length && !notes) return cleanOutcome;
const t = tags.join(" · ");
return t && notes ? `${t} — ${notes}` : t || notes;
@@ -80,6 +90,7 @@ export const formatOutcome = (outcome) => {
// Crop health status
export const deriveCropStatus = (payload) => {
if (!payload) return "Healthy";
+
const s = extractSensors(payload);
const ph = parseFloat(s.ph) || 0;
const ec = parseFloat(s.ec) || 0;
@@ -87,7 +98,7 @@ export const deriveCropStatus = (payload) => {
const outcome = (payload.outcome || "").toLowerCase();
const action = (payload.action_taken || "").toUpperCase();
- // Critical Thresholds
+ // Critical thresholds
if (
(ph > 0 && ph < 4.5) ||
ph > 7.5 ||
@@ -96,11 +107,10 @@ export const deriveCropStatus = (payload) => {
temp > 35 ||
/fail|critical|disease|error/.test(outcome) ||
/DISEASE|FUNGAL|PEST/.test(action)
- ) {
+ )
return "Critical";
- }
- // Warning/Attention Thresholds
+ // Warning thresholds
if (
(ph > 0 && ph < 5.5) ||
ph > 6.5 ||
@@ -109,9 +119,397 @@ export const deriveCropStatus = (payload) => {
temp > 30 ||
/deteriorat|negative|attention|decline/.test(outcome) ||
/FLUSH|PRUNE|BOOST/.test(action)
- ) {
+ )
return "Attention";
- }
return "Healthy";
};
+
+// Alert generation
+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`;
+}
+
+export const 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
+ if (ph > 0 && ph < 4.5)
+ alerts.push({
+ id: id++,
+ severity: "critical",
+ title: "pH critically low",
+ desc: `${cropName} (${cropId}): pH at ${ph} — 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} — acid dosing required immediately.`,
+ time: timeAgo(ts),
+ ts,
+ agent: "WATER",
+ crop: cropName,
+ ack: false,
+ });
+
+ 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.`,
+ time: timeAgo(ts),
+ ts,
+ agent: "WATER",
+ crop: cropName,
+ ack: false,
+ });
+
+ if (temp > 0 && temp < 10)
+ alerts.push({
+ id: id++,
+ severity: "critical",
+ title: "Temperature too cold",
+ desc: `${cropName} (${cropId}): Air temp at ${temp}°C — 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,
+ });
+
+ 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. Outcome: "${formatOutcome(payload.outcome)}"`,
+ time: timeAgo(ts),
+ ts,
+ agent: "DOCTOR",
+ crop: cropName,
+ ack: false,
+ });
+
+ if (/fail|critical|error/.test(outcome))
+ alerts.push({
+ id: id++,
+ severity: "critical",
+ title: "Cycle failure recorded",
+ desc: `${cropName} (${cropId}): Seq #${seq} outcome: "${formatOutcome(payload.outcome)}"`,
+ time: timeAgo(ts),
+ ts,
+ agent: "JUDGE",
+ crop: cropName,
+ ack: false,
+ });
+
+ // Warning
+ 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.`,
+ 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.`,
+ time: timeAgo(ts),
+ ts,
+ agent: "WATER",
+ crop: cropName,
+ ack: false,
+ });
+
+ 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,
+ });
+
+ if (temp >= 10 && temp < 17)
+ alerts.push({
+ id: id++,
+ severity: "warning",
+ title: "Temperature on the low side",
+ desc: `${cropName} (${cropId}): ${temp}°C — slow growth 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.`,
+ time: timeAgo(ts),
+ ts,
+ agent: "ATMOSPHERIC",
+ crop: cropName,
+ ack: false,
+ });
+
+ if (/deteriorat|negative|attention|decline/.test(outcome))
+ alerts.push({
+ id: id++,
+ severity: "warning",
+ title: "Condition deteriorating",
+ desc: `${cropName} (${cropId}): Seq #${seq} — "${formatOutcome(payload.outcome)}"`,
+ time: timeAgo(ts),
+ ts,
+ agent: "JUDGE",
+ crop: cropName,
+ ack: false,
+ });
+
+ // Info
+ 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,
+ });
+ }
+
+ // Deduplicate — max 2 per 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 < 2) {
+ deduped.push(a);
+ seen.set(key, count + 1);
+ }
+ }
+
+ const sevOrder = { critical: 0, warning: 1, info: 2 };
+ deduped.sort((a, b) =>
+ sevOrder[a.severity] !== sevOrder[b.severity]
+ ? sevOrder[a.severity] - sevOrder[b.severity]
+ : new Date(b.ts || 0) - new Date(a.ts || 0),
+ );
+
+ return deduped;
+};
+
+// Analytics helpers
+export const avg = (arr) => {
+ if (!arr.length) return 0;
+ return arr.reduce((s, v) => s + v, 0) / arr.length;
+};
+
+export const safePct = (current, previous) => {
+ if (!previous) return 0;
+ return parseFloat((((current - previous) / previous) * 100).toFixed(1));
+};
+
+export const bucketHistory = (points, range) => {
+ if (!points.length) return [];
+
+ const now = Date.now();
+ const MS = { "24h": 86400000, "7d": 604800000, "30d": 2592000000 };
+ const cutoff = now - (MS[range] || MS["24h"]);
+
+ const filtered = points.filter(
+ (p) => new Date(p.payload?.timestamp || 0).getTime() >= cutoff,
+ );
+ if (!filtered.length) return [];
+
+ const bucketSize = range === "24h" ? 3600000 : 86400000;
+ 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 - b)
+ .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,
+ };
+ });
+};
+
+export const 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),
+ }));
+};
+
+export const 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,
+ ),
+ },
+ ];
+};
+
+export const buildAgentStats = (points) => {
+ const AGENTS = ["SUPERVISOR", "WATER", "ATMOSPHERIC", "JUDGE", "DOCTOR"];
+ const counts = Object.fromEntries(AGENTS.map((a) => [a, 0]));
+ for (const p of points) {
+ const text =
+ `${p.payload?.action_taken || ""} ${p.payload?.strategic_intent || ""}`.toUpperCase();
+ for (const agent of AGENTS) if (text.includes(agent)) counts[agent]++;
+ counts["SUPERVISOR"]++;
+ }
+ const total = Math.max(points.length, 1);
+ return AGENTS.map((name) => ({
+ name,
+ decisions: counts[name],
+ accuracy: points.length
+ ? Math.round(
+ (points.filter(
+ (p) =>
+ !/fail|negative|critical/.test(
+ (p.payload?.outcome || "").toLowerCase(),
+ ),
+ ).length /
+ total) *
+ 100,
+ )
+ : 0,
+ })).filter((a) => a.decisions > 0);
+};