demeter

Autonomous Hydroponic Intelligence

frontend/src/pages/Alerts.jsx (17009B)


  1import { useState, useEffect, useMemo } from "react";
  2import {
  3  AlertTriangle,
  4  CheckCircle2,
  5  Info,
  6  Zap,
  7  X,
  8  Bell,
  9  BellOff,
 10  Clock,
 11  RefreshCw,
 12  SlidersHorizontal,
 13  Scissors,
 14  RotateCcw,
 15  Leaf,
 16} from "lucide-react";
 17import { useFarmData } from "../hooks/useFarmData";
 18import { generateAlerts } from "../utils/dataUtils";
 19import {
 20  PageShell,
 21  PageHeader,
 22  IconButton,
 23  FilterPill,
 24  LoadingShimmer,
 25  EmptyState,
 26} from "../components/ui";
 27import { useT } from "../hooks/useTranslation";
 28
 29// Severity
 30const SEV = {
 31  critical: {
 32    icon: AlertTriangle,
 33    bg: "rgba(248,113,113,0.1)",
 34    border: "rgba(248,113,113,0.3)",
 35    text: "var(--red)",
 36    labelKey: "alerts_severity_critical",
 37  },
 38  warning: {
 39    icon: Zap,
 40    bg: "rgba(245,158,11,0.1)",
 41    border: "rgba(245,158,11,0.25)",
 42    text: "var(--amber)",
 43    labelKey: "alerts_severity_warning",
 44  },
 45  info: {
 46    icon: Info,
 47    bg: "rgba(96,165,250,0.1)",
 48    border: "rgba(96,165,250,0.25)",
 49    text: "var(--blue)",
 50    labelKey: "alerts_severity_info",
 51  },
 52};
 53
 54const HARVEST_STYLE = {
 55  icon: Scissors,
 56  bg: "rgba(74,222,128,0.1)",
 57  border: "rgba(74,222,128,0.3)",
 58  text: "var(--green)",
 59  labelKey: "alerts_severity_harvest",
 60};
 61
 62const AGENT_COLORS = {
 63  WATER: "var(--blue)",
 64  ATMOSPHERIC: "#a78bfa",
 65  SUPERVISOR: "var(--green)",
 66  JUDGE: "var(--amber)",
 67  DOCTOR: "var(--red)",
 68  HISTORIAN: "var(--text-3)",
 69};
 70
 71function AlertCard({ alert, onAck, onUnack, onDismiss, t }) {
 72  const style = alert.isHarvestAlert
 73    ? HARVEST_STYLE
 74    : SEV[alert.severity] || SEV.info;
 75  const Icon = style.icon || Info;
 76
 77  // Friendly agent label
 78  const agentLabel = alert.agent
 79    ? alert.agent.charAt(0) + alert.agent.slice(1).toLowerCase() + " Agent"
 80    : "System";
 81
 82  const cropDisplay =
 83    alert.crop && alert.crop !== "Unknown Crop"
 84      ? alert.crop
 85      : alert.cropId || "Unknown";
 86
 87  return (
 88    <div
 89      className="card-hover"
 90      style={{
 91        borderRadius: 12,
 92        padding: 16,
 93        background: alert.ack ? "var(--surface)" : style.bg,
 94        border: `1px solid ${alert.ack ? "var(--border)" : style.border}`,
 95        opacity: alert.ack ? 0.55 : 1,
 96        transition: "opacity 0.2s",
 97      }}
 98    >
 99      <div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
100        {/* Severity icon */}
101        <div
102          style={{
103            width: 34,
104            height: 34,
105            borderRadius: 10,
106            background: style.bg,
107            border: `1px solid ${style.border}`,
108            display: "flex",
109            alignItems: "center",
110            justifyContent: "center",
111            flexShrink: 0,
112            marginTop: 2,
113          }}
114        >
115          <Icon size={15} style={{ color: style.text }} />
116        </div>
117
118        {/* Content */}
119        <div style={{ flex: 1, minWidth: 0 }}>
120          {/* Title row */}
121          <div
122            style={{
123              display: "flex",
124              alignItems: "center",
125              gap: 7,
126              flexWrap: "wrap",
127              marginBottom: 4,
128            }}
129          >
130            <span
131              style={{
132                fontWeight: 600,
133                fontSize: 13,
134                color: alert.ack ? "var(--text-2)" : "var(--text)",
135                lineHeight: 1.3,
136              }}
137            >
138              {alert.title}
139            </span>
140
141            {/* Severity badge */}
142            <span
143              style={{
144                fontSize: 9,
145                fontFamily: "DM Mono, monospace",
146                padding: "2px 7px",
147                borderRadius: 4,
148                background: style.bg,
149                color: style.text,
150                border: `1px solid ${style.border}`,
151                letterSpacing: "0.04em",
152                textTransform: "uppercase",
153              }}
154            >
155              {t(style.labelKey)}
156            </span>
157
158            {/* Agent badge */}
159            <span
160              style={{
161                fontSize: 9,
162                fontFamily: "DM Mono, monospace",
163                padding: "2px 7px",
164                borderRadius: 4,
165                background: "var(--bg-3)",
166                color: AGENT_COLORS[alert.agent] || "var(--text-3)",
167                border: "1px solid var(--border)",
168              }}
169            >
170              {agentLabel}
171            </span>
172          </div>
173
174          {/* Description */}
175          <p
176            style={{
177              fontSize: 12,
178              color: "var(--text-2)",
179              margin: "0 0 8px",
180              lineHeight: 1.6,
181            }}
182          >
183            {alert.desc}
184          </p>
185
186          {/* Meta row: time + crop */}
187          <div
188            style={{
189              display: "flex",
190              alignItems: "center",
191              gap: 12,
192              flexWrap: "wrap",
193            }}
194          >
195            <span
196              style={{
197                display: "flex",
198                alignItems: "center",
199                gap: 4,
200                fontSize: 10,
201                fontFamily: "DM Mono, monospace",
202                color: "var(--text-3)",
203              }}
204            >
205              <Clock size={9} />
206              {alert.time}
207            </span>
208
209            <span
210              style={{
211                display: "flex",
212                alignItems: "center",
213                gap: 4,
214                fontSize: 10,
215                fontFamily: "DM Mono, monospace",
216                color: "var(--text-3)",
217              }}
218            >
219              <Leaf size={9} style={{ color: "var(--green)" }} />
220              <span style={{ color: "var(--text-2)", fontWeight: 500 }}>
221                {cropDisplay}
222              </span>
223              {alert.cropId && alert.cropId !== cropDisplay && (
224                <span style={{ opacity: 0.55 }}>· {alert.cropId}</span>
225              )}
226            </span>
227          </div>
228        </div>
229
230        {/* Action buttons */}
231        <div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
232          {!alert.ack ? (
233            <button
234              onClick={() => onAck(alert.id)}
235              title="Acknowledge"
236              style={{
237                width: 28,
238                height: 28,
239                borderRadius: 8,
240                background: "var(--surface)",
241                border: "1px solid var(--border)",
242                color: "var(--text-3)",
243                display: "flex",
244                alignItems: "center",
245                justifyContent: "center",
246                cursor: "pointer",
247                transition: "background 150ms, color 150ms",
248              }}
249            >
250              <CheckCircle2 size={13} />
251            </button>
252          ) : (
253            <button
254              onClick={() => onUnack(alert.id)}
255              title="Unacknowledge"
256              style={{
257                width: 28,
258                height: 28,
259                borderRadius: 8,
260                background: "var(--surface)",
261                border: "1px solid var(--border)",
262                color: "var(--text-3)",
263                display: "flex",
264                alignItems: "center",
265                justifyContent: "center",
266                cursor: "pointer",
267              }}
268            >
269              <RotateCcw size={13} />
270            </button>
271          )}
272          <button
273            onClick={() => onDismiss(alert.id)}
274            title="Dismiss"
275            style={{
276              width: 28,
277              height: 28,
278              borderRadius: 8,
279              background: "var(--surface)",
280              border: "1px solid var(--border)",
281              color: "var(--text-3)",
282              display: "flex",
283              alignItems: "center",
284              justifyContent: "center",
285              cursor: "pointer",
286            }}
287          >
288            <X size={13} />
289          </button>
290        </div>
291      </div>
292    </div>
293  );
294}
295
296// MAIN
297
298export default function Alerts() {
299  const { history, loading, refreshData } = useFarmData();
300  const { t, td } = useT();
301  const [alerts, setAlerts] = useState([]);
302  const [filter, setFilter] = useState("all");
303  const [showAcked, setShowAcked] = useState(false);
304
305  useEffect(() => {
306    if (!loading) setAlerts(generateAlerts(history, t));
307  }, [history, loading, t]);
308
309  const ack = (id) =>
310    setAlerts((a) => a.map((al) => (al.id === id ? { ...al, ack: true } : al)));
311  const unack = (id) =>
312    setAlerts((a) =>
313      a.map((al) => (al.id === id ? { ...al, ack: false } : al)),
314    );
315  const dismiss = (id) => setAlerts((a) => a.filter((al) => al.id !== id));
316  const ackAll = () => setAlerts((a) => a.map((al) => ({ ...al, ack: true })));
317
318  const counts = useMemo(
319    () => ({
320      harvest: alerts.filter((a) => a.isHarvestAlert && (showAcked || !a.ack))
321        .length,
322      critical: alerts.filter(
323        (a) =>
324          a.severity === "critical" &&
325          (showAcked || !a.ack) &&
326          !a.isHarvestAlert,
327      ).length,
328      warning: alerts.filter(
329        (a) => a.severity === "warning" && (showAcked || !a.ack),
330      ).length,
331      info: alerts.filter(
332        (a) =>
333          a.severity === "info" && (showAcked || !a.ack) && !a.isHarvestAlert,
334      ).length,
335      total: alerts.filter((a) => showAcked || !a.ack).length,
336    }),
337    [alerts, showAcked],
338  );
339
340  const filtered = useMemo(
341    () =>
342      alerts.filter((a) => {
343        if (!showAcked && a.ack) return false;
344        if (filter === "harvest") return a.isHarvestAlert;
345        if (filter !== "all" && a.severity !== filter) return false;
346        return true;
347      }),
348    [alerts, filter, showAcked],
349  );
350
351  const unackedList = filtered.filter((a) => !a.ack);
352  const ackedList = filtered.filter((a) => a.ack);
353
354  const FILTER_OPTIONS = [
355    { key: "all", label: t("common_all"), count: counts.total, color: null },
356    {
357      key: "harvest",
358      label: t("alerts_filter_harvest"),
359      count: counts.harvest,
360      color: "var(--green)",
361    },
362    {
363      key: "critical",
364      label: t("dash_critical"),
365      count: counts.critical,
366      color: "var(--red)",
367    },
368    {
369      key: "warning",
370      label: t("alerts_filter_warning"),
371      count: counts.warning,
372      color: "var(--amber)",
373    },
374    {
375      key: "info",
376      label: t("alerts_filter_info"),
377      count: counts.info,
378      color: "var(--blue)",
379    },
380  ];
381
382  return (
383    <PageShell>
384      {/* Header */}
385      <PageHeader
386        title={t("alerts_title")}
387        subtitle={
388          loading
389            ? t("alerts_subtitle_loading")
390            : t("alerts_subtitle", {
391                unacked: counts.total,
392                total: alerts.length,
393              })
394        }
395      >
396        <div
397          style={{
398            marginLeft: "auto",
399            display: "flex",
400            alignItems: "center",
401            gap: 8,
402          }}
403        >
404          <IconButton onClick={refreshData}>
405            <RefreshCw size={14} className={loading ? "animate-spin" : ""} />
406          </IconButton>
407
408          <button
409            onClick={() => setShowAcked(!showAcked)}
410            style={{
411              display: "flex",
412              alignItems: "center",
413              gap: 6,
414              padding: "6px 12px",
415              borderRadius: 8,
416              fontSize: 11,
417              fontFamily: "DM Mono, monospace",
418              background: showAcked ? "rgba(74,222,128,0.1)" : "var(--surface)",
419              border: `1px solid ${showAcked ? "rgba(74,222,128,0.3)" : "var(--border)"}`,
420              color: showAcked ? "var(--green)" : "var(--text-3)",
421              cursor: "pointer",
422            }}
423          >
424            {showAcked ? <BellOff size={12} /> : <Bell size={12} />}
425            {showAcked ? t("alerts_unacked_only") : t("alerts_show_all")}
426          </button>
427
428          <button
429            onClick={ackAll}
430            style={{
431              display: "flex",
432              alignItems: "center",
433              gap: 6,
434              padding: "6px 12px",
435              borderRadius: 8,
436              fontSize: 11,
437              fontFamily: "DM Mono, monospace",
438              background: "var(--surface)",
439              border: "1px solid var(--border)",
440              color: "var(--text-3)",
441              cursor: "pointer",
442            }}
443          >
444            <CheckCircle2 size={12} /> {t("alerts_ack_all_btn")}
445          </button>
446        </div>
447      </PageHeader>
448
449      {/* Filter bar */}
450      <div
451        style={{
452          flexShrink: 0,
453          padding: "8px 24px",
454          borderBottom: "1px solid var(--border)",
455          background: "var(--bg-2)",
456          display: "flex",
457          alignItems: "center",
458          gap: 8,
459          overflowX: "auto",
460        }}
461      >
462        <SlidersHorizontal
463          size={14}
464          style={{ color: "var(--text-3)", flexShrink: 0 }}
465        />
466        {FILTER_OPTIONS.map(({ key, label, count, color }) => (
467          <FilterPill
468            key={key}
469            active={filter === key}
470            onClick={() => setFilter(key)}
471            style={{
472              display: "flex",
473              alignItems: "center",
474              gap: 6,
475              color: filter === key ? color || "var(--text)" : "var(--text-3)",
476            }}
477          >
478            {count > 0 && (
479              <span
480                style={{
481                  width: 18,
482                  height: 18,
483                  borderRadius: "50%",
484                  display: "flex",
485                  alignItems: "center",
486                  justifyContent: "center",
487                  fontSize: 9,
488                  background: color ? `${color}30` : "var(--border)",
489                  color: color || "var(--text-3)",
490                }}
491              >
492                {count}
493              </span>
494            )}
495            {label}
496          </FilterPill>
497        ))}
498      </div>
499
500      {/* Alert list */}
501      <div style={{ flex: 1, overflowY: "auto", padding: 24 }}>
502        {loading ? (
503          <LoadingShimmer count={3} height={80} />
504        ) : filtered.length === 0 ? (
505          <EmptyState
506            icon={CheckCircle2}
507            title={
508              alerts.length === 0
509                ? t("alerts_empty_nodata")
510                : t("alerts_empty_connected")
511            }
512          />
513        ) : (
514          <div
515            style={{
516              display: "flex",
517              flexDirection: "column",
518              gap: 24,
519            }}
520          >
521            {/* Active (unacknowledged) */}
522            {unackedList.length > 0 && (
523              <div>
524                <div
525                  style={{
526                    fontSize: 10,
527                    fontFamily: "DM Mono, monospace",
528                    color: "var(--text-3)",
529                    marginBottom: 10,
530                    display: "flex",
531                    alignItems: "center",
532                    gap: 6,
533                  }}
534                >
535                  <span
536                    style={{
537                      width: 6,
538                      height: 6,
539                      borderRadius: "50%",
540                      background: "var(--red)",
541                      display: "inline-block",
542                    }}
543                  />
544                  {t("alerts_unacked", { n: unackedList.length })}
545                  <span style={{ marginLeft: "auto", opacity: 0.5 }}>
546                    newest first
547                  </span>
548                </div>
549                <div
550                  style={{ display: "flex", flexDirection: "column", gap: 8 }}
551                >
552                  {unackedList.map((a) => (
553                    <AlertCard
554                      key={a.id}
555                      alert={a}
556                      onAck={ack}
557                      onUnack={unack}
558                      onDismiss={dismiss}
559                      t={t}
560                      td={td}
561                    />
562                  ))}
563                </div>
564              </div>
565            )}
566
567            {/* Acknowledged */}
568            {showAcked && ackedList.length > 0 && (
569              <div>
570                <div
571                  style={{
572                    fontSize: 10,
573                    fontFamily: "DM Mono, monospace",
574                    color: "var(--text-3)",
575                    marginBottom: 10,
576                  }}
577                >
578                  {t("alerts_acked", { n: ackedList.length })}
579                </div>
580                <div
581                  style={{ display: "flex", flexDirection: "column", gap: 8 }}
582                >
583                  {ackedList.map((a) => (
584                    <AlertCard
585                      key={a.id}
586                      alert={a}
587                      onAck={ack}
588                      onUnack={unack}
589                      onDismiss={dismiss}
590                      t={t}
591                      td={td}
592                    />
593                  ))}
594                </div>
595              </div>
596            )}
597          </div>
598        )}
599      </div>
600    </PageShell>
601  );
602}