demeter
Autonomous Hydroponic Intelligencegit clone https://github.com/maydayv7/demeter
frontend/src/pages/RunCycle.jsx (35825B)
1import { useRef, useState, useEffect, useCallback } from "react";
2import { useParams, useNavigate } from "react-router-dom";
3import { useT } from "../hooks/useTranslation";
4import {
5 Upload,
6 ArrowLeft,
7 Activity,
8 Droplets,
9 Thermometer,
10 Wind,
11 Play,
12 CheckCircle2,
13 AlertTriangle,
14 Brain,
15 Waves,
16 FlaskConical,
17 Cpu,
18 Leaf,
19 RotateCcw,
20 Zap,
21} from "lucide-react";
22import {
23 PageShell,
24 PageHeader,
25 IconButton,
26 SectionCard,
27} from "../components/ui";
28import { fetchCropById } from "../api/farmApi";
29import {
30 extractSensors,
31 getCurrentStage,
32 calculateMaturity,
33 getDaysRemaining,
34 CROP_LIFECYCLES,
35} from "../utils/dataUtils";
36import { AgentActionWidget } from "../components/AgentWidgets";
37
38// Agent log helpers
39
40const AGENT_META = {
41 FETCHER: { color: "#60a5fa", bg: "rgba(96,165,250,0.12)", label: "Fetcher" },
42 JUDGE: { color: "#f59e0b", bg: "rgba(245,158,11,0.12)", label: "Judge" },
43 RESEARCHER: {
44 color: "#a78bfa",
45 bg: "rgba(167,139,250,0.12)",
46 label: "Researcher",
47 },
48 ATMOSPHERIC: {
49 color: "#4ade80",
50 bg: "rgba(74,222,128,0.12)",
51 label: "Atmospheric",
52 },
53 WATER: { color: "#22d3ee", bg: "rgba(34,211,238,0.12)", label: "Water" },
54 SUPERVISOR: {
55 color: "#f97316",
56 bg: "rgba(249,115,22,0.12)",
57 label: "Supervisor",
58 },
59 BANDIT: { color: "#e879f9", bg: "rgba(232,121,249,0.12)", label: "Bandit" },
60 DOCTOR: { color: "#f87171", bg: "rgba(248,113,113,0.12)", label: "Doctor" },
61 SYSTEM: { color: "#6a8a6d", bg: "rgba(106,138,109,0.12)", label: "System" },
62};
63
64function agentFromLine(line) {
65 const up = line.toUpperCase();
66 if (up.includes("[FETCHER]") || up.includes("FETCHING")) return "FETCHER";
67 if (up.includes("[JUDGE]") || up.includes("JUDGE")) return "JUDGE";
68 if (up.includes("BANDIT") || up.includes("STRATEGY")) return "BANDIT";
69 if (up.includes("RESEARCH")) return "RESEARCHER";
70 if (up.includes("ATMO") || up.includes("ATMOSPHERIC")) return "ATMOSPHERIC";
71 if (up.includes("WATER") || up.includes("NUTRIENT")) return "WATER";
72 if (up.includes("SUPERVISOR")) return "SUPERVISOR";
73 if (up.includes("DOCTOR") || up.includes("VISION") || up.includes("DIAGNOS"))
74 return "DOCTOR";
75 return "SYSTEM";
76}
77
78function levelFromLine(line) {
79 const up = line.toUpperCase();
80 if (up.includes("❌") || up.includes("ERROR") || up.includes("CRITICAL"))
81 return "error";
82 if (up.includes("⚠️") || up.includes("WARN") || up.includes("FAIL"))
83 return "warn";
84 if (up.includes("✅") || up.includes("APPROV") || up.includes("SUCCESS"))
85 return "success";
86 return "info";
87}
88
89const LEVEL_COLORS = {
90 error: "var(--red)",
91 warn: "var(--amber)",
92 success: "var(--green)",
93 info: "var(--text-2)",
94};
95
96function phaseFromLogs(logs) {
97 const last = logs[logs.length - 1]?.text?.toUpperCase() || "";
98 if (last.includes("SENT TO SIMULATOR") || last.includes("CYCLE COMPLETE"))
99 return "execute";
100 if (last.includes("ACTIVATING") || last.includes("SUPERVISOR"))
101 return "execute";
102 if (
103 last.includes("WATER") ||
104 last.includes("ATMOSPHERIC") ||
105 last.includes("MERGING")
106 )
107 return "plan";
108 if (last.includes("RESEARCH") || last.includes("KNOWLEDGE"))
109 return "research";
110 if (last.includes("BANDIT") || last.includes("STRATEGY")) return "strategy";
111 if (last.includes("JUDGE")) return "judge";
112 if (last.includes("FETCHER") || last.includes("FMU")) return "fetch";
113 return null;
114}
115
116function LogLine({ entry, idx }) {
117 const agent = AGENT_META[entry.agent] || AGENT_META.SYSTEM;
118 const lvlColor = LEVEL_COLORS[entry.level] || LEVEL_COLORS.info;
119 return (
120 <div
121 className="animate-fade-in"
122 style={{
123 display: "flex",
124 alignItems: "flex-start",
125 gap: 8,
126 padding: "5px 0",
127 borderBottom: "1px solid rgba(128,180,128,0.06)",
128 animationDelay: `${Math.min(idx * 20, 300)}ms`,
129 }}
130 >
131 {/* Line number */}
132 <span
133 style={{
134 fontSize: 9,
135 fontFamily: "DM Mono, monospace",
136 color: "var(--text-3)",
137 minWidth: 26,
138 paddingTop: 2,
139 opacity: 0.5,
140 }}
141 >
142 {String(idx + 1).padStart(3, "0")}
143 </span>
144 {/* Timestamp */}
145 <span
146 style={{
147 fontSize: 9,
148 fontFamily: "DM Mono, monospace",
149 color: "var(--text-3)",
150 minWidth: 58,
151 paddingTop: 2,
152 flexShrink: 0,
153 }}
154 >
155 {entry.time}
156 </span>
157 {/* Agent badge */}
158 <span
159 style={{
160 fontSize: 8,
161 fontFamily: "DM Mono, monospace",
162 padding: "2px 6px",
163 borderRadius: 4,
164 background: agent.bg,
165 color: agent.color,
166 border: `1px solid ${agent.color}30`,
167 flexShrink: 0,
168 minWidth: 76,
169 textAlign: "center",
170 marginTop: 1,
171 letterSpacing: "0.03em",
172 }}
173 >
174 {agent.label.toUpperCase()}
175 </span>
176 {/* Message */}
177 <span
178 style={{
179 fontSize: 11,
180 fontFamily: "DM Mono, monospace",
181 color: lvlColor,
182 flex: 1,
183 wordBreak: "break-word",
184 lineHeight: 1.55,
185 }}
186 >
187 {entry.text}
188 </span>
189 </div>
190 );
191}
192
193// Pipeline phase config
194const PIPELINE_PHASES = [
195 { id: "fetch", label: "Fetch", icon: Waves },
196 { id: "judge", label: "Judge", icon: AlertTriangle },
197 { id: "strategy", label: "Strategy", icon: Brain },
198 { id: "research", label: "Research", icon: FlaskConical },
199 { id: "plan", label: "Plan", icon: Cpu },
200 { id: "execute", label: "Execute", icon: Zap },
201];
202const PHASE_ORDER = PIPELINE_PHASES.map((p) => p.id);
203
204// MAIN
205
206export default function RunCycle() {
207 const { cropId } = useParams();
208 const navigate = useNavigate();
209 const { t } = useT();
210 const AGENT_API =
211 process.env.REACT_APP_AGENT_API_URL || "http://localhost:8000";
212
213 const [cropDoc, setCropDoc] = useState(null);
214 const [loadingCrop, setLoadingCrop] = useState(true);
215 const [sensors, setSensors] = useState({
216 pH: "6.0",
217 EC: "1.4",
218 temp: "24.0",
219 humidity: "65.0",
220 });
221 const [file, setFile] = useState(null);
222 const [preview, setPreview] = useState(null);
223 const [phase, setPhase] = useState("idle"); // idle | running | done | error
224 const [logs, setLogs] = useState([]);
225 const [cycles, setCycles] = useState(0);
226 const [activePhase, setActivePhase] = useState(null);
227 const [finalAction, setFinalAction] = useState(null);
228 const [toast, setToast] = useState(null);
229
230 const logEndRef = useRef(null);
231 const timersRef = useRef([]);
232
233 useEffect(() => {
234 if (!cropId) return;
235 fetchCropById(cropId).then((doc) => {
236 if (doc) {
237 setCropDoc(doc);
238 const s = extractSensors(doc);
239 setSensors({
240 pH: String(s.ph),
241 EC: String(s.ec),
242 temp: String(s.temp),
243 humidity: String(s.humidity),
244 });
245 }
246 setLoadingCrop(false);
247 });
248 }, [cropId]);
249
250 useEffect(() => {
251 logEndRef.current?.scrollIntoView({ behavior: "smooth" });
252 }, [logs]);
253
254 useEffect(() => () => timersRef.current.forEach(clearTimeout), []);
255
256 const showToast = (msg, type = "success") => {
257 setToast({ msg, type });
258 setTimeout(() => setToast(null), 3500);
259 };
260
261 const pushLog = useCallback((text, agentKey) => {
262 const now = new Date();
263 const time = now.toLocaleTimeString([], {
264 hour: "2-digit",
265 minute: "2-digit",
266 second: "2-digit",
267 });
268 const agent = agentKey || agentFromLine(text);
269 const level = levelFromLine(text);
270 setLogs((prev) => {
271 const updated = [...prev, { text, time, agent, level }];
272 const newPhase = phaseFromLogs(updated);
273 if (newPhase) setActivePhase(newPhase);
274 return updated;
275 });
276 }, []);
277
278 const handleFileChange = (e) => {
279 const f = e.target.files?.[0];
280 if (!f) return;
281 setFile(f);
282 const r = new FileReader();
283 r.onload = (ev) => setPreview(ev.target.result);
284 r.readAsDataURL(f);
285 };
286
287 const runCycle = async () => {
288 setPhase("running");
289 setLogs([]);
290 setActivePhase("fetch");
291 setFinalAction(null);
292
293 const cropName = cropDoc?.crop || "Unknown";
294 const stage = cropDoc?.stage || "seedling";
295 const cropIdVal = cropDoc?.crop_id || cropId;
296
297 try {
298 const formData = new FormData();
299 if (file) {
300 formData.append("file", file);
301 } else {
302 const canvas = document.createElement("canvas");
303 canvas.width = 32;
304 canvas.height = 32;
305 await new Promise((res) =>
306 canvas.toBlob((b) => {
307 formData.append("file", b, "placeholder.png");
308 res();
309 }),
310 );
311 }
312 formData.append(
313 "sensors",
314 JSON.stringify({
315 pH: parseFloat(sensors.pH),
316 EC: parseFloat(sensors.EC),
317 temp: parseFloat(sensors.temp),
318 humidity: parseFloat(sensors.humidity),
319 crop_id: cropIdVal,
320 }),
321 );
322 formData.append(
323 "metadata",
324 JSON.stringify({ crop: cropName, stage, crop_id: cropIdVal }),
325 );
326
327 pushLog(
328 `Starting cycle for ${cropIdVal} (${cropName} - ${stage})`,
329 "FETCHER",
330 );
331
332 const res = await fetch(`${AGENT_API}/run-cycle-stream`, {
333 method: "POST",
334 body: formData,
335 });
336 if (!res.ok) throw new Error(`HTTP ${res.status}`);
337
338 const reader = res.body.getReader();
339 const decoder = new TextDecoder();
340 let buffer = "";
341
342 while (true) {
343 const { done, value } = await reader.read();
344 if (done) break;
345 buffer += decoder.decode(value, { stream: true });
346 const lines = buffer.split("\n");
347 buffer = lines.pop() || "";
348
349 for (const line of lines) {
350 const trimmed = line.trim();
351 if (!trimmed) continue;
352 if (trimmed.startsWith("data:")) {
353 const raw = trimmed.slice(5).trim();
354 if (raw === "[DONE]") {
355 setPhase("done");
356 setCycles((c) => c + 1);
357 break;
358 }
359 try {
360 const parsed = JSON.parse(raw);
361 const msg = parsed.log || parsed.text;
362 if (msg) pushLog(msg, parsed.agent?.toUpperCase?.());
363 if (parsed.action || parsed.final_action) {
364 setFinalAction(parsed.action || parsed.final_action);
365 pushLog("✅ Final action dispatched to hardware", "SUPERVISOR");
366 }
367 if (parsed.phase === "done") {
368 setPhase("done");
369 setCycles((c) => c + 1);
370 showToast(t("add_cycle_done"));
371 }
372 } catch {
373 pushLog(raw);
374 }
375 } else {
376 pushLog(trimmed);
377 }
378 }
379 }
380
381 setPhase("done");
382 setCycles((c) => c + 1);
383 showToast(t("add_cycle_done"));
384 } catch (err) {
385 setPhase("error");
386 pushLog(`❌ ${err.message || t("add_cycle_fail")}`, "SYSTEM");
387 showToast(t("add_cycle_fail"), "error");
388 }
389 };
390
391 // Derived values
392 const cropName = cropDoc?.crop || cropId;
393 const maturity = cropDoc ? calculateMaturity(cropDoc) : 0;
394 const daysLeft = cropDoc ? getDaysRemaining(cropDoc) : null;
395 const lifecycle = CROP_LIFECYCLES[(cropDoc?.crop || "").toLowerCase()];
396 const curStage = cropDoc
397 ? getCurrentStage(cropDoc) || cropDoc?.stage || "-"
398 : "-";
399
400 if (loadingCrop) {
401 return (
402 <PageShell>
403 <div
404 style={{
405 flex: 1,
406 display: "flex",
407 alignItems: "center",
408 justifyContent: "center",
409 }}
410 >
411 <span
412 style={{
413 fontSize: 13,
414 fontFamily: "DM Mono, monospace",
415 color: "var(--text-3)",
416 }}
417 >
418 {t("run_loading_crop")}
419 </span>
420 </div>
421 </PageShell>
422 );
423 }
424
425 return (
426 <PageShell>
427 {/* Toast */}
428 {toast && (
429 <div
430 className="animate-fade-in"
431 style={{
432 position: "fixed",
433 top: 20,
434 right: 20,
435 zIndex: 9999,
436 padding: "12px 20px",
437 borderRadius: 12,
438 background:
439 toast.type === "error"
440 ? "rgba(248,113,113,0.15)"
441 : "rgba(74,222,128,0.15)",
442 border: `1px solid ${toast.type === "error" ? "var(--red)" : "var(--green)"}`,
443 color: toast.type === "error" ? "var(--red)" : "var(--green)",
444 fontFamily: "DM Mono, monospace",
445 fontSize: 13,
446 }}
447 >
448 {toast.msg}
449 </div>
450 )}
451
452 {/* Header */}
453 <PageHeader>
454 <IconButton onClick={() => navigate(`/crop/${cropId}`)}>
455 <ArrowLeft size={15} />
456 </IconButton>
457 <div>
458 <h1 className="page-title">{t("run_title")}</h1>
459 <p className="page-subtitle">
460 {t("run_for_crop", { crop: cropName })}
461 </p>
462 </div>
463 </PageHeader>
464
465 {phase === "done" && (
466 <div
467 style={{
468 display: "flex",
469 alignItems: "center",
470 justifyContent: "center",
471 gap: 8,
472 padding: "8px 0px",
473 borderBottom: "1px solid rgba(74,222,128,0.15)",
474 background: "rgba(74,222,128,0.06)",
475 }}
476 >
477 <CheckCircle2 size={13} style={{ color: "var(--green)" }} />
478 <span
479 style={{
480 fontSize: 12,
481 fontFamily: "DM Mono, monospace",
482 color: "var(--green)",
483 fontWeight: 600,
484 }}
485 >
486 {t("run_cycles_complete", {
487 n: cycles,
488 s: cycles !== 1 ? "s" : "",
489 })}
490 </span>
491 </div>
492 )}
493
494 <div
495 style={{
496 flex: 1,
497 overflowY: "auto",
498 padding: 24,
499 display: "flex",
500 flexDirection: "column",
501 gap: 18,
502 }}
503 >
504 {/* Crop Info */}
505 {cropDoc && (
506 <SectionCard>
507 <div
508 style={{
509 display: "flex",
510 alignItems: "center",
511 gap: 16,
512 flexWrap: "wrap",
513 }}
514 >
515 {/* Crop avatar */}
516 <div
517 style={{
518 width: 52,
519 height: 52,
520 borderRadius: 14,
521 background: "rgba(74,222,128,0.1)",
522 border: "1px solid rgba(74,222,128,0.25)",
523 display: "flex",
524 alignItems: "center",
525 justifyContent: "center",
526 flexShrink: 0,
527 }}
528 >
529 <Leaf size={24} style={{ color: "var(--green)" }} />
530 </div>
531
532 {/* Name + stage */}
533 <div style={{ flex: 1 }}>
534 <div
535 style={{
536 fontWeight: 700,
537 fontSize: 16,
538 color: "var(--text)",
539 }}
540 >
541 {cropName}
542 </div>
543 <div
544 style={{
545 display: "flex",
546 alignItems: "center",
547 gap: 8,
548 marginTop: 4,
549 flexWrap: "wrap",
550 }}
551 >
552 <span
553 style={{
554 fontSize: 10,
555 fontFamily: "DM Mono, monospace",
556 color: "var(--text-3)",
557 padding: "2px 8px",
558 borderRadius: 4,
559 background: "var(--bg-3)",
560 border: "1px solid var(--border)",
561 textTransform: "capitalize",
562 }}
563 >
564 {curStage}
565 </span>
566 <span
567 style={{
568 fontSize: 10,
569 fontFamily: "DM Mono, monospace",
570 color: "var(--text-3)",
571 }}
572 >
573 {cropId}
574 </span>
575 {daysLeft !== null && (
576 <span
577 style={{
578 fontSize: 10,
579 fontFamily: "DM Mono, monospace",
580 color: daysLeft === 0 ? "var(--green)" : "var(--amber)",
581 }}
582 >
583 {daysLeft === 0
584 ? t("run_harvest_ready")
585 : t("run_days_until", { n: daysLeft })}
586 </span>
587 )}
588 </div>
589 </div>
590
591 {/* Lifecycle progress bar */}
592 <div style={{ minWidth: 300 }}>
593 <div
594 style={{
595 display: "flex",
596 justifyContent: "space-between",
597 marginBottom: 5,
598 }}
599 >
600 <span
601 style={{
602 fontSize: 10,
603 fontFamily: "DM Mono, monospace",
604 color: "var(--text-3)",
605 }}
606 >
607 {t("run_growth_progress")}
608 </span>
609 <span
610 style={{
611 fontSize: 10,
612 fontFamily: "DM Mono, monospace",
613 fontWeight: 700,
614 color:
615 maturity >= 95
616 ? "var(--green)"
617 : maturity >= 70
618 ? "var(--amber)"
619 : "var(--blue)",
620 }}
621 >
622 {maturity}%
623 </span>
624 </div>
625 <div
626 style={{
627 height: 6,
628 borderRadius: 3,
629 background: "var(--border)",
630 overflow: "hidden",
631 }}
632 >
633 <div
634 style={{
635 width: `${maturity}%`,
636 height: "100%",
637 borderRadius: 3,
638 background:
639 maturity >= 95
640 ? "var(--green)"
641 : maturity >= 70
642 ? "var(--amber)"
643 : "linear-gradient(90deg, #22d3ee, #4ade80)",
644 transition: "width 0.5s ease",
645 }}
646 />
647 </div>
648 {/* Stage labels */}
649 {lifecycle && (
650 <div style={{ display: "flex", marginTop: 5, gap: 0 }}>
651 {lifecycle.stages.map((s, i) => {
652 const endH = s.endH ?? lifecycle.totalHours;
653 const w =
654 ((endH - s.startH) / lifecycle.totalHours) * 100;
655 const stageColors = [
656 "var(--text-3)",
657 "#22d3ee",
658 "var(--amber)",
659 "var(--green)",
660 ];
661 return (
662 <div
663 key={s.name}
664 style={{ width: `${w}%`, textAlign: "center" }}
665 title={`${s.name}: ${Math.round(((s.endH ?? lifecycle.totalHours) - s.startH) / 24)}d`}
666 >
667 <div
668 style={{
669 height: 3,
670 background: stageColors[i % stageColors.length],
671 opacity: 0.5,
672 marginBottom: 3,
673 }}
674 />
675 <span
676 style={{
677 fontSize: 8,
678 fontFamily: "DM Mono, monospace",
679 color: stageColors[i % stageColors.length],
680 textTransform: "capitalize",
681 opacity: 0.8,
682 whiteSpace: "nowrap",
683 overflow: "hidden",
684 display: "block",
685 }}
686 >
687 {s.name}
688 </span>
689 </div>
690 );
691 })}
692 </div>
693 )}
694 </div>
695 </div>
696 </SectionCard>
697 )}
698
699 {/* Pipeline Progress Bar */}
700 <div
701 style={{
702 padding: "14px 18px",
703 borderRadius: 14,
704 background: "var(--bg-3)",
705 border: "1px solid var(--border)",
706 }}
707 >
708 <div
709 style={{
710 display: "grid",
711 gridTemplateColumns: `repeat(${PIPELINE_PHASES.length}, 1fr)`,
712 gap: 4,
713 alignItems: "start",
714 }}
715 >
716 {PIPELINE_PHASES.map((p, i) => {
717 const pIdx = PHASE_ORDER.indexOf(p.id);
718 const activeIdx = PHASE_ORDER.indexOf(activePhase || "");
719 const isDone = phase === "done" || pIdx < activeIdx;
720 const isActive = p.id === activePhase && phase === "running";
721 const PIcon = p.icon;
722
723 return (
724 <div
725 key={p.id}
726 style={{
727 display: "flex",
728 flexDirection: "column",
729 alignItems: "center",
730 gap: 6,
731 position: "relative",
732 }}
733 >
734 {/* Connector line */}
735 {i < PIPELINE_PHASES.length - 1 && (
736 <div
737 style={{
738 position: "absolute",
739 top: 18,
740 left: "calc(50% + 14px)",
741 right: "calc(-50% + 14px)",
742 height: 1,
743 background: isDone
744 ? "rgba(74,222,128,0.5)"
745 : "var(--border)",
746 transition: "background 0.4s",
747 }}
748 />
749 )}
750
751 {/* Circle icon */}
752 <div
753 style={{
754 width: 36,
755 height: 36,
756 borderRadius: "50%",
757 background: isDone
758 ? "rgba(74,222,128,0.15)"
759 : isActive
760 ? "rgba(74,222,128,0.08)"
761 : "var(--bg-2)",
762 border: `2px solid ${
763 isDone
764 ? "rgba(74,222,128,0.5)"
765 : isActive
766 ? "rgba(74,222,128,0.4)"
767 : "var(--border)"
768 }`,
769 display: "flex",
770 alignItems: "center",
771 justifyContent: "center",
772 transition: "all 0.3s",
773 zIndex: 1,
774 }}
775 >
776 {isDone ? (
777 <CheckCircle2
778 size={14}
779 style={{ color: "var(--green)" }}
780 />
781 ) : isActive ? (
782 <PIcon
783 size={14}
784 style={{
785 color: "var(--green)",
786 animation: "spin 1.5s linear infinite",
787 }}
788 />
789 ) : (
790 <PIcon
791 size={14}
792 style={{ color: "var(--text-3)", opacity: 0.4 }}
793 />
794 )}
795 </div>
796
797 {/* Label */}
798 <span
799 style={{
800 fontSize: 9,
801 fontFamily: "DM Mono, monospace",
802 color: isDone
803 ? "var(--green)"
804 : isActive
805 ? "var(--green)"
806 : "var(--text-3)",
807 fontWeight: isActive ? 700 : 400,
808 textAlign: "center",
809 lineHeight: 1.2,
810 }}
811 >
812 {p.label}
813 </span>
814 </div>
815 );
816 })}
817 </div>
818 </div>
819
820 {/* Sensor Inputs */}
821 <SectionCard>
822 <div className="section-label" style={{ marginBottom: 12 }}>
823 {t("add_sensor_params")}
824 </div>
825 <div
826 style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}
827 >
828 {[
829 {
830 label: t("add_field_ph"),
831 name: "pH",
832 icon: Droplets,
833 color: "var(--blue)",
834 step: "0.1",
835 min: "0",
836 max: "14",
837 },
838 {
839 label: t("add_field_ec"),
840 name: "EC",
841 icon: Activity,
842 color: "var(--amber)",
843 step: "0.1",
844 },
845 {
846 label: t("add_field_temp"),
847 name: "temp",
848 icon: Thermometer,
849 color: "var(--red)",
850 step: "0.5",
851 },
852 {
853 label: t("add_field_humidity"),
854 name: "humidity",
855 icon: Wind,
856 color: "#a78bfa",
857 step: "1",
858 },
859 ].map(({ label, name, icon: Icon, color, step, min, max }) => (
860 <div
861 key={name}
862 style={{
863 padding: "12px 14px",
864 borderRadius: 10,
865 background: "var(--bg-3)",
866 border: "1px solid var(--border)",
867 transition: "border-color 150ms",
868 }}
869 >
870 <div
871 style={{
872 display: "flex",
873 alignItems: "center",
874 gap: 6,
875 marginBottom: 6,
876 }}
877 >
878 <Icon size={12} style={{ color }} />
879 <span
880 style={{
881 fontSize: 10,
882 fontFamily: "DM Mono, monospace",
883 color: "var(--text-3)",
884 }}
885 >
886 {label}
887 </span>
888 </div>
889 <input
890 type="number"
891 step={step}
892 min={min}
893 max={max}
894 value={sensors[name]}
895 onChange={(e) =>
896 setSensors((s) => ({ ...s, [name]: e.target.value }))
897 }
898 disabled={phase === "running"}
899 style={{
900 width: "100%",
901 background: "none",
902 border: "none",
903 outline: "none",
904 color,
905 fontFamily: "DM Mono, monospace",
906 fontSize: 20,
907 fontWeight: 700,
908 }}
909 />
910 </div>
911 ))}
912 </div>
913 </SectionCard>
914
915 {/* Image Upload */}
916 <SectionCard>
917 <div className="section-label" style={{ marginBottom: 12 }}>
918 {t("add_plant_image")}
919 </div>
920 {preview ? (
921 <div
922 style={{
923 position: "relative",
924 borderRadius: 12,
925 overflow: "hidden",
926 height: 160,
927 }}
928 >
929 <img
930 src={preview}
931 alt="crop"
932 style={{ width: "100%", height: "100%", objectFit: "cover" }}
933 />
934 <button
935 onClick={() => {
936 setFile(null);
937 setPreview(null);
938 }}
939 style={{
940 position: "absolute",
941 top: 8,
942 right: 8,
943 padding: "4px 10px",
944 borderRadius: 8,
945 background: "rgba(0,0,0,0.6)",
946 border: "none",
947 color: "#fff",
948 fontSize: 11,
949 cursor: "pointer",
950 }}
951 >
952 Remove
953 </button>
954 </div>
955 ) : (
956 <label
957 style={{
958 display: "flex",
959 flexDirection: "column",
960 alignItems: "center",
961 gap: 8,
962 padding: 24,
963 borderRadius: 12,
964 border: "2px dashed var(--border)",
965 cursor: "pointer",
966 background: "var(--bg-3)",
967 }}
968 >
969 <Upload size={22} style={{ color: "var(--text-3)" }} />
970 <span
971 style={{
972 fontSize: 12,
973 fontFamily: "DM Mono, monospace",
974 color: "var(--text-3)",
975 }}
976 >
977 {t("add_drop_image")}
978 </span>
979 <span
980 style={{
981 fontSize: 10,
982 color: "var(--text-3)",
983 opacity: 0.6,
984 fontFamily: "DM Mono, monospace",
985 }}
986 >
987 {t("add_image_hint")}
988 </span>
989 <input
990 type="file"
991 accept="image/*"
992 onChange={handleFileChange}
993 style={{ display: "none" }}
994 />
995 </label>
996 )}
997 </SectionCard>
998
999 {/* Agent Log */}
1000 {(logs.length > 0 || phase === "running") && (
1001 <SectionCard>
1002 <div
1003 style={{
1004 display: "flex",
1005 alignItems: "center",
1006 justifyContent: "space-between",
1007 marginBottom: 10,
1008 }}
1009 >
1010 <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
1011 <div className="section-label">
1012 {phase === "running"
1013 ? t("add_log_live")
1014 : phase === "done"
1015 ? t("add_log_done")
1016 : t("add_log_idle")}
1017 </div>
1018 {phase === "running" && (
1019 <span
1020 style={{
1021 fontSize: 9,
1022 fontFamily: "DM Mono, monospace",
1023 padding: "2px 8px",
1024 borderRadius: 10,
1025 background: "rgba(248,113,113,0.12)",
1026 color: "var(--red)",
1027 border: "1px solid rgba(248,113,113,0.3)",
1028 animation: "pulse 1.5s ease-in-out infinite",
1029 marginTop: "-12px",
1030 }}
1031 >
1032 {t("run_streaming")}
1033 </span>
1034 )}
1035 </div>
1036 <span
1037 style={{
1038 fontSize: 10,
1039 fontFamily: "DM Mono, monospace",
1040 color: "var(--text-3)",
1041 }}
1042 >
1043 {logs.length} lines
1044 </span>
1045 </div>
1046
1047 {/* Terminal-style log box */}
1048 <div
1049 style={{
1050 background: "rgba(0,0,0,0.25)",
1051 borderRadius: 10,
1052 border: "1px solid rgba(128,180,128,0.1)",
1053 padding: "10px 12px",
1054 maxHeight: 320,
1055 overflowY: "auto",
1056 }}
1057 >
1058 {logs.length === 0 ? (
1059 <div
1060 style={{
1061 textAlign: "center",
1062 padding: "20px 0",
1063 color: "var(--text-3)",
1064 fontFamily: "DM Mono, monospace",
1065 fontSize: 11,
1066 }}
1067 >
1068 {t("run_log_waiting")}
1069 </div>
1070 ) : (
1071 logs.map((entry, i) => (
1072 <LogLine key={i} entry={entry} idx={i} />
1073 ))
1074 )}
1075 <div ref={logEndRef} />
1076 </div>
1077 </SectionCard>
1078 )}
1079
1080 {/* Final Action */}
1081 {finalAction && (
1082 <SectionCard>
1083 <div className="section-label" style={{ marginBottom: 12 }}>
1084 {t("add_actuator_dispatched")}
1085 </div>
1086 <AgentActionWidget actionTaken={finalAction} compact={false} />
1087 </SectionCard>
1088 )}
1089
1090 {/* Run Button */}
1091 <div style={{ paddingBottom: 24 }}>
1092 <button
1093 onClick={phase === "running" ? undefined : runCycle}
1094 disabled={phase === "running"}
1095 style={{
1096 width: "100%",
1097 padding: "16px 24px",
1098 borderRadius: 14,
1099 background:
1100 phase === "running"
1101 ? "rgba(74,222,128,0.06)"
1102 : phase === "done"
1103 ? "rgba(74,222,128,0.12)"
1104 : "var(--green)",
1105 border:
1106 phase === "running"
1107 ? "1px solid rgba(74,222,128,0.2)"
1108 : phase === "done"
1109 ? "1px solid rgba(74,222,128,0.35)"
1110 : "none",
1111 color:
1112 phase === "running"
1113 ? "var(--green)"
1114 : phase === "done"
1115 ? "var(--green)"
1116 : "var(--btn-on-green)",
1117 fontWeight: 700,
1118 fontSize: 15,
1119 cursor: phase === "running" ? "not-allowed" : "pointer",
1120 display: "flex",
1121 alignItems: "center",
1122 justifyContent: "center",
1123 gap: 10,
1124 transition: "all 0.2s",
1125 letterSpacing: "0.02em",
1126 }}
1127 >
1128 {phase === "running" ? (
1129 <>
1130 <Brain size={18} style={{ animation: "pulse 1s infinite" }} />
1131 {t("run_agents_working")}
1132 </>
1133 ) : phase === "done" ? (
1134 <>
1135 <RotateCcw size={17} />
1136 {t("add_run_another")}
1137 </>
1138 ) : (
1139 <>
1140 <Play size={17} />
1141 {t("run_title")}
1142 </>
1143 )}
1144 </button>
1145 </div>
1146 </div>
1147
1148 <style>{`
1149 @keyframes spin {
1150 from { transform: rotate(0deg); }
1151 to { transform: rotate(360deg); }
1152 }
1153 @keyframes pulse {
1154 0%, 100% { opacity: 1; }
1155 50% { opacity: 0.4; }
1156 }
1157 `}</style>
1158 </PageShell>
1159 );
1160}