demeter

Autonomous Hydroponic Intelligence

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}