// screens-work.jsx — Telemetry, Microclimate, Recommendations, Tasks (Kanban + List), Commands, OTA, Reports, Settings /* ─── CP-8 Live telemetry ─────────────────────────────────── */ function Telemetry() { const TelemetryChart = window.TelemetryChart; const devices = Array.isArray(DATA?.devices) ? DATA.devices : []; const [selected, setSelected] = React.useState(devices[0]?.id || ""); React.useEffect(() => { if (!devices.length) return; if (!selected || !devices.some((d) => d.id === selected)) { setSelected(devices[0].id); } }, [selected, devices]); const dev = devices.find((d) => d.id === selected) || devices[0] || null; const events = [ { t:"14:38:12", lvl:"info", msg:"sample EC_sub=2,4 EC_drain=2,0 flow=1,2 CO2=812 lux=18400 t=21,4°C" }, { t:"14:38:08", lvl:"info", msg:"sample EC_sub=2,3 EC_drain=1,9 flow=1,1 CO2=808 lux=18350 t=21,5°C" }, { t:"14:38:04", lvl:"warn", msg:"battery dropped to 12% — schedule replacement" }, { t:"14:38:00", lvl:"info", msg:"sample EC_sub=2,4 EC_drain=2,1 flow=1,2 CO2=815 lux=18420 t=21,4°C" }, { t:"14:37:56", lvl:"info", msg:"heartbeat ok rssi=−62 fw=v1.8.0" }, { t:"14:37:48", lvl:"crit", msg:"packet dropped seq=4421 (3rd in 30s)" }, { t:"14:37:40", lvl:"info", msg:"sample EC_sub=2,4 EC_drain=2,0 flow=1,3 CO2=810 lux=18380 t=21,3°C" }, { t:"14:37:32", lvl:"ok", msg:"calibration check passed" }, { t:"14:37:24", lvl:"info", msg:"sample EC_sub=2,3 EC_drain=1,8 flow=1,1 CO2=805 lux=18310 t=21,4°C" }, ]; return (
Живая телеметрия
История датчиков и микроклимата по выбранному устройству
Автообновление 60с (1h)
EC субстрата EC дренажа Расход полива Температура Влажность CO₂ Освещённость Активность Батарея Все зоны
Устройства{devices.length}
{devices.map(d=>(
setSelected(d.id)}>
{d.name} {d.zone}
))}
{dev && (
{dev.name}
{dev.id} · {dev.zone}
{dev.statusLbl}
)}
{!dev && (
Устройства не найдены
)} {/* [TASK_START:T006] */} {dev && TelemetryChart && [ { title: "Температура", metric_key: "temperature_c", unit: "°C", accent: "#E5342B" }, { title: "Влажность", metric_key: "humidity_pct", unit: "%", accent: "#007BFB" }, { title: "CO₂", metric_key: "co2_ppm", unit: "ppm", accent: "#7FB539" }, { title: "Освещённость", metric_key: "insolation_lux", unit: "lux", accent: "#E8B84A" }, { title: "Влажность субстрата", metric_key: "substrate_moisture_pct", unit: "%", accent: "#00A3A3" }, { title: "EC дренажа", metric_key: "drain_ec", unit: "mS/cm", accent: "#FF7A00" }, ].map((chart) => ( ))} {/* [TASK_COMPLETE:T006] */}
{events.map((e,i)=>(
{e.lvl} {e.msg}
))}
); } /* ─── Microclimate dashboard ──────────────────────────────── */ function metricDisplayName(metric) { if (metric === "temperature_c") return "Температура"; if (metric === "humidity_pct") return "Влажность"; if (metric === "co2_ppm") return "CO₂"; if (metric === "insolation_lux") return "Освещённость"; return metric || "—"; } function toMicroclimateFallback(zoneId, range) { const micro = (DATA && DATA.telemetry && DATA.telemetry.microclimate) || {}; return { composite: { zone_id: zoneId, range, generated_at: new Date().toISOString(), data_available: true, composite_score: null, metrics: [ { metric: "temperature_c", unit: "°C", current_value: micro.temperature_c ?? null, status: "unknown" }, { metric: "humidity_pct", unit: "%", current_value: micro.humidity_pct ?? null, status: "unknown" }, { metric: "co2_ppm", unit: "ppm", current_value: micro.co2_ppm ?? null, status: "unknown" }, { metric: "insolation_lux", unit: "lux", current_value: micro.insolation_lux ?? null, status: "unknown" }, ], }, history_comparison: { metrics: [] }, zone_comparison: { metrics: [] }, }; } function Microclimate() { const zoneOptions = React.useMemo(() => { const byGreenhouse = (DATA && DATA.zones) || {}; return Object.keys(byGreenhouse).flatMap((greenhouseId) => { const rows = Array.isArray(byGreenhouse[greenhouseId]) ? byGreenhouse[greenhouseId] : []; return rows.map((row) => ({ greenhouseId, zoneId: row.id, label: row.row_label || row.id, })); }); }, []); const [selectedZoneId, setSelectedZoneId] = React.useState(() => zoneOptions[0]?.zoneId || ""); const [range, setRange] = React.useState("24h"); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(""); const [payload, setPayload] = React.useState(null); React.useEffect(() => { if (!selectedZoneId) return; let cancelled = false; async function loadMicroclimate() { setLoading(true); setError(""); try { const response = await Keeper.api.zoneMicroclimateDashboard(selectedZoneId, { range }); if (!cancelled) setPayload(response); } catch (_) { if (!cancelled) { setError("Модуль микроклимата временно недоступен"); setPayload(toMicroclimateFallback(selectedZoneId, range)); } } finally { if (!cancelled) setLoading(false); } } loadMicroclimate(); return () => { cancelled = true; }; }, [selectedZoneId, range]); const composite = payload && payload.composite ? payload.composite : null; const history = payload && payload.history_comparison ? payload.history_comparison : null; const zoneComparison = payload && payload.zone_comparison ? payload.zone_comparison : null; const metrics = composite && Array.isArray(composite.metrics) ? composite.metrics : []; return (
Микроклимат
Сводные метрики зоны, динамика и сравнение с соседними зонами
{["24h", "48h", "7d"].map((option) => ( ))}
{error &&
{error}
}
Индекс микроклимата
{composite && composite.composite_score != null ? `${Math.round(Number(composite.composite_score) * 100)}%` : "—"}
Диапазон: {composite?.range || range}
Точек данных
{composite?.data_points ?? 0}
Зона: {composite?.zone_id || selectedZoneId || "—"}
Сгенерировано
{composite?.generated_at ? new Date(composite.generated_at).toLocaleString("ru-RU") : "—"}
{loading ? "Обновление..." : "Актуально"}

Текущие метрики

{!loading && metrics.length === 0 &&
Нет данных для выбранной зоны
} {metrics.map((row) => ( ))}
Метрика Значение База Оптимум Статус
{metricDisplayName(row.metric)} {row.current_value != null ? `${row.current_value} ${row.unit || ""}` : "—"} {row.baseline_value != null ? `${row.baseline_value} ${row.unit || ""}` : "—"} {row.optimal_min != null || row.optimal_max != null ? `${row.optimal_min ?? "—"}…${row.optimal_max ?? "—"} ${row.unit || ""}` : "—"} {row.status || "unknown"}

Историческое сравнение

{history?.metrics?.length ? (
    {history.metrics.map((row) => (
  • {metricDisplayName(row.metric)}: {row.absolute_delta != null ? row.absolute_delta : "—"} {row.unit || ""} ({row.trend || "unknown"})
  • ))}
) : (
Сравнение периодов пока недоступно
)}

Сравнение зон

{zoneComparison?.metrics?.length ? (
    {zoneComparison.metrics.map((row) => (
  • {metricDisplayName(row.metric)}: ранг {row.rank ?? "—"} из {row.peer_count ?? 0}
  • ))}
) : (
Сравнение с соседними зонами пока недоступно
)}
); } /* ─── CP-10 Recommendations ───────────────────────────────── */ function normalizeRecommendationSeverity(raw) { const value = String(raw || "").toLowerCase(); if (value === "critical" || value === "cri") return "critical"; if (value === "warning" || value === "warn") return "warning"; return "info"; } function normalizeRecommendationStatus(raw, applied, dismissed) { const value = String(raw || "").toLowerCase(); if (value === "applied" || applied) return "applied"; if (value === "dismissed" || dismissed) return "dismissed"; return "active"; } function recommendationSeverityLabel(severity) { if (severity === "critical") return "Critical"; if (severity === "warning") return "Warning"; return "Info"; } // [TASK_START:T008] function RecommendationSeverityBadge({ severity }) { return ( {recommendationSeverityLabel(severity)} ); } // [TASK_COMPLETE:T008] function recommendationSeverityClass(severity) { if (severity === "critical") return "cri"; if (severity === "warning") return "warn"; return "info"; } function recommendationSourceLabel(source) { const value = String(source || "").toLowerCase(); if (value === "sensor_analysis") return "Sensor analysis"; if (value === "alert_correlation") return "Alert correlation"; if (value === "scheduled_inspection") return "Scheduled inspection"; if (value === "agronomist") return "Agronomist"; return "System"; } function recommendationStatusLabel(status) { if (status === "applied") return "Applied"; if (status === "dismissed") return "Dismissed"; return "Active"; } function recommendationActionIcon(severity) { if (severity === "critical") return "ri-alarm-warning-line"; if (severity === "warning") return "ri-error-warning-line"; return "ri-lightbulb-line"; } function toRecoViewModel(raw) { const severity = normalizeRecommendationSeverity(raw?.severity || raw?.kind); const status = normalizeRecommendationStatus(raw?.status, raw?.applied, raw?.dismissed); // [TASK_START:T012] const expiresAt = raw?.expires_at || raw?.expiresAt || null; const expiresAtMs = expiresAt ? Date.parse(expiresAt) : Number.NaN; const isExpired = Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now(); // [TASK_COMPLETE:T012] return { id: raw?.id || `rec-${Math.random().toString(16).slice(2, 8)}`, tenant_id: raw?.tenant_id || null, greenhouse_id: raw?.greenhouse_id || null, zone_id: raw?.zone_id || null, severity, source: raw?.source || "sensor_analysis", status, title: raw?.title || "Recommendation", detail: raw?.detail || raw?.desc || "", created_at: raw?.created_at || raw?.createdAt || null, expires_at: expiresAt, is_expired: isExpired, icon: raw?.icon || recommendationActionIcon(severity), applied: Boolean(raw?.applied || status === "applied"), dismissed: Boolean(raw?.dismissed || status === "dismissed"), }; } function formatRecommendationTime(ts) { if (!ts) return "—"; const value = Date.parse(ts); if (!Number.isFinite(value)) return String(ts); return new Date(value).toLocaleString(); } function Recommendations({ onNav }) { const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(""); const [recommendations, setRecommendations] = React.useState([]); const [statusFilter, setStatusFilter] = React.useState("active"); const [severityFilter, setSeverityFilter] = React.useState("all"); const [pendingAction, setPendingAction] = React.useState(null); // { id, type, text, submitting, error } React.useEffect(() => { let cancelled = false; async function loadRecommendations() { setLoading(true); setError(""); try { // [TASK_START:T006] const rows = await Keeper.api.recommendations(); const list = Array.isArray(rows) ? rows.map(toRecoViewModel) : []; // [TASK_COMPLETE:T006] if (!cancelled) setRecommendations(list); } catch (_) { if (!cancelled) { // [TASK_START:T013] setError("Рекомендации временно недоступны"); const fallback = Array.isArray(DATA?.recommendations) ? DATA.recommendations.map(toRecoViewModel) : []; setRecommendations(fallback); // [TASK_COMPLETE:T013] } } finally { if (!cancelled) setLoading(false); } } loadRecommendations(); return () => { cancelled = true; }; }, []); const statusCounts = React.useMemo(() => { const counts = { active: 0, applied: 0, dismissed: 0 }; recommendations.forEach((rec) => { const value = rec.status; if (value === "applied") counts.applied += 1; else if (value === "dismissed") counts.dismissed += 1; else counts.active += 1; }); return counts; }, [recommendations]); // [TASK_START:T009] const visibleRecommendations = React.useMemo(() => { return recommendations.filter((rec) => { if (statusFilter !== "all" && rec.status !== statusFilter) return false; if (severityFilter !== "all" && rec.severity !== severityFilter) return false; return true; }); }, [recommendations, statusFilter, severityFilter]); // [TASK_COMPLETE:T009] function beginAction(recId, type) { setPendingAction({ id: recId, type, text: "", submitting: false, error: "" }); } function cancelAction() { setPendingAction(null); } async function confirmAction() { if (!pendingAction) return; const prev = recommendations; const { id, type, text } = pendingAction; // [TASK_START:T007] if (type === "apply") { setRecommendations((rows) => rows.map((row) => row.id === id ? { ...row, status: "applied", applied: true, dismissed: false } : row ) ); } else { setRecommendations((rows) => rows.map((row) => row.id === id ? { ...row, status: "dismissed", dismissed: true, applied: false } : row ) ); } // [TASK_COMPLETE:T007] setPendingAction((state) => ({ ...state, submitting: true, error: "" })); try { if (type === "apply") { await Keeper.api.applyRecommendation(id, text ? { note: text } : undefined); } else { await Keeper.api.dismissRecommendation(id, text ? { reason: text } : undefined); } setPendingAction(null); } catch (_) { setRecommendations(prev); setPendingAction((state) => ({ ...state, submitting: false, error: type === "apply" ? "Не удалось применить рекомендацию" : "Не удалось отклонить рекомендацию", })); } } return (
Рекомендации
Приоритизированные действия по зонам и теплицам
{/* [TASK_START:T013] */} {error &&
{error}
} {/* [TASK_COMPLETE:T013] */}
{loading &&
Загрузка рекомендаций…
} {!loading && recommendations.length === 0 && (

Рекомендаций пока нет

Когда аналитика сформирует новые рекомендации, они появятся здесь.

)} {!loading && recommendations.length > 0 && visibleRecommendations.length === 0 && (
Нет рекомендаций для выбранных фильтров
)} {visibleRecommendations.map((r)=>(
{recommendationStatusLabel(r.status)} {/* [TASK_START:T012] */} {r.is_expired && Expired} {/* [TASK_COMPLETE:T012] */}

{r.title}

{r.detail}
{recommendationSourceLabel(r.source)} {r.zone_id || "—"} Expires: {formatRecommendationTime(r.expires_at)}
{r.id} Created: {formatRecommendationTime(r.created_at)}
{pendingAction && pendingAction.id === r.id && ( <> {/* [TASK_START:T010] */}