demeter
Autonomous Hydroponic Intelligencegit clone https://github.com/maydayv7/demeter
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}