// HiveDetail.jsx — slide-in panel with per-hive gate control const GATE_COMMAND_STATUS_VIEW = { pending: { cls: "info", label: "В очереди" }, sent: { cls: "warn", label: "Отправлена" }, applied: { cls: "ok", label: "Выполнено" }, failed: { cls: "critical", label: "Ошибка" }, timed_out: { cls: "warn", label: "Нет ответа" }, }; function gateCommandStatusBadge(status) { const cfg = GATE_COMMAND_STATUS_VIEW[status] || { cls: "muted", label: status || "—" }; return ( {cfg.label} ); } function formatHiveCommandTs(value) { if (!value) return "—"; const dt = new Date(value); if (Number.isNaN(dt.getTime())) return String(value); return dt.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", }); } function findHiveFallbackById(hiveId) { const byGreenhouse = (DATA && DATA.hives) || {}; const all = Object.values(byGreenhouse).flat(); const row = all.find((item) => item.id === hiveId); if (!row) return null; return { id: row.id, name: `Улей ${row.id}`, zone_id: row.zone_id || null, status: row.state || row.status || "ok", activity_per_min: row.departures_per_min, gate_status: row.gate_status, gate_device_id: row.gate_device_id || null, }; } function deriveGateState(hive) { const state = String(hive?.gate_status || hive?.gate_mode || "").toLowerCase(); if (state === "open") return "open"; if (state === "closed") return "closed"; if (state === "offline") return "offline"; return "closed"; } function isUnavailableStatus(status) { return ( status === "critical" || status === "crit" || status === "warn" || status === "warning" || status === "offline" ); } function mapHiveStateClass(status) { if (status === "critical" || status === "crit") return "tri-cri"; if (status === "warn" || status === "warning") return "tri-att"; return "tri-ok"; } function runGateSmokeAssertions() { if (window.__keeperGateSmokeChecked) return; window.__keeperGateSmokeChecked = true; console.assert(isUnavailableStatus("critical") === true, "gate smoke: critical must disable controls"); console.assert(isUnavailableStatus("warn") === true, "gate smoke: warn must disable controls"); console.assert(isUnavailableStatus("ok") === false, "gate smoke: ok must allow controls"); console.assert(deriveGateState({ gate_status: "open" }) === "open", "gate smoke: open state mapping"); } // [TASK_START:T005] function GateConfirmModal({ action, hiveId, gateDeviceId, onCancel, onConfirm, loading }) { React.useEffect(() => { if (!action) return undefined; function onEsc(e) { if (e.key === "Escape" && !loading) onCancel(); } window.addEventListener("keydown", onEsc); return () => window.removeEventListener("keydown", onEsc); }, [action, loading, onCancel]); if (!action) return null; const isOpenAction = action === "open"; return (
!loading && onCancel()}>
e.stopPropagation()}>
Подтвердите команду
{isOpenAction ? "Открыть" : "Закрыть"} задвижку для выбранного улья
Действие
{isOpenAction ? "Открыть задвижку" : "Закрыть задвижку"}
Hive ID
{hiveId}
Gate Device
{gateDeviceId || "—"}
); } // [TASK_COMPLETE:T005] // [TASK_START:T006] function HiveGateControl({ hive, gateDeviceStatus }) { const GateBadge = window.GateStatusBadge; const gateDeviceId = hive?.gate_device_id || null; const unavailable = !gateDeviceId || isUnavailableStatus(gateDeviceStatus); const [confirmAction, setConfirmAction] = React.useState(null); const [submitting, setSubmitting] = React.useState(false); const [inlineError, setInlineError] = React.useState(""); const [history, setHistory] = React.useState([]); const [gateState, setGateState] = React.useState(deriveGateState(hive)); const [latestCommandId, setLatestCommandId] = React.useState(""); const [latestCommandStatus, setLatestCommandStatus] = React.useState(""); React.useEffect(() => { runGateSmokeAssertions(); }, []); React.useEffect(() => { setGateState(deriveGateState(hive)); setInlineError(""); setLatestCommandId(""); setLatestCommandStatus(""); setHistory([]); }, [hive?.id]); React.useEffect(() => { if (!latestCommandId || (latestCommandStatus !== "pending" && latestCommandStatus !== "sent")) return undefined; let cancelled = false; const startedAt = Date.now(); const timer = setInterval(async () => { if (cancelled || Date.now() - startedAt < 10000) return; try { const [command, audit] = await Promise.all([ Keeper.api.command(latestCommandId), Keeper.api.getCommandAudit(latestCommandId, 10), ]); if (cancelled) return; const latestAudit = Array.isArray(audit?.entries) ? audit.entries[0] : null; const nextStatus = latestAudit?.status || command?.status; if (!nextStatus || nextStatus === latestCommandStatus) return; setLatestCommandStatus(nextStatus); setHistory((prev) => prev.map((row) => row.id === latestCommandId ? { ...row, status: nextStatus } : row)); if (nextStatus === "timed_out") { setInlineError("Нет ответа от устройства"); } } catch (_) { // Polling is best-effort; do not interrupt the operator flow on transient errors. } }, 2500); return () => { cancelled = true; clearInterval(timer); }; }, [latestCommandId, latestCommandStatus]); function appendHistoryRow(commandId, action, actor, status, issuedAt) { const actionLabel = action === "open" ? "Открыть задвижку" : "Закрыть задвижку"; const row = { id: commandId, action: actionLabel, actor: actor || "ui", at: issuedAt || new Date().toISOString(), status: status || "pending", }; setHistory((prev) => [row, ...prev].slice(0, 5)); } async function execute(action) { if (!hive?.id || !gateDeviceId) return; const previousGateState = gateState; const issuedBy = window.DATA?.user?.initials || window.DATA?.user?.name || "ui"; setConfirmAction(null); setSubmitting(true); setInlineError(""); setGateState("transitioning"); setLatestCommandStatus("pending"); try { const command = action === "open" ? await Keeper.api.openHiveGate(hive.id, issuedBy) : await Keeper.api.closeHiveGate(hive.id, issuedBy); const status = command?.status || "pending"; const commandId = command?.id || `CMD-local-${Date.now()}`; setLatestCommandId(commandId); setLatestCommandStatus(status); appendHistoryRow(commandId, action, command?.issued_by || issuedBy, status, command?.issued_at); if (status === "applied") { setGateState(action === "open" ? "open" : "closed"); } else if (status === "failed") { setGateState(previousGateState); setInlineError("Команда не выполнена · код 500"); } } catch (e) { setGateState(previousGateState); setLatestCommandStatus("failed"); const code = e && e.status ? e.status : "?"; setInlineError(`Команда не выполнена · код ${code}`); appendHistoryRow(`LOCAL-FAIL-${Date.now()}`, action, issuedBy, "failed", new Date().toISOString()); } finally { setSubmitting(false); } } const visualState = unavailable && gateDeviceId ? "offline" : gateState; return (
Управление задвижкой
Hive: {hive.id} · Device: {gateDeviceId || "—"}
{GateBadge ? : {visualState}} {latestCommandStatus ? gateCommandStatusBadge(latestCommandStatus) : null}
{!gateDeviceId && (
Задвижка не назначена
)} {isUnavailableStatus(gateDeviceStatus) && (
Устройство недоступно
)} {inlineError && (
{inlineError}
)} {/* [TASK_START:T007] */}
Последние команды
{history.length === 0 ? (
Команды ещё не отправлялись в текущей сессии
) : ( {history.map((row) => ( ))}
ID Действие Кем Время Статус
{row.id} {row.action} {row.actor} {formatHiveCommandTs(row.at)} {gateCommandStatusBadge(row.status)}
)}
{/* [TASK_COMPLETE:T007] */} setConfirmAction(null)} onConfirm={() => execute(confirmAction)} loading={submitting} />
); } // [TASK_COMPLETE:T006] function HiveDetail({ hiveId, onClose }) { const [hive, setHive] = React.useState(null); const [loading, setLoading] = React.useState(true); const [gateDeviceStatus, setGateDeviceStatus] = React.useState("ok"); React.useEffect(() => { let cancelled = false; async function loadHive() { setLoading(true); setGateDeviceStatus("ok"); let currentHive = null; try { currentHive = await Keeper.api.hive(hiveId); } catch (_) { currentHive = findHiveFallbackById(hiveId); } if (cancelled) return; setHive(currentHive); if (currentHive?.gate_device_id) { try { const gateDevice = await Keeper.api.device(currentHive.gate_device_id); if (!cancelled) { setGateDeviceStatus(gateDevice?.status || "ok"); } } catch (_) { if (!cancelled) { setGateDeviceStatus(currentHive?.status || "ok"); } } } if (!cancelled) setLoading(false); } if (hiveId) loadHive(); else { setHive(null); setLoading(false); } return () => { cancelled = true; }; }, [hiveId]); if (!hiveId) return null; const stateClass = mapHiveStateClass(hive?.status); const title = hive?.name || `Улей ${hiveId}`; return (
{title}
{hive?.zone_id ? `Зона ${hive.zone_id}` : "Зона —"} {hive?.station ? ` · ${hive.station}` : ""} {hive?.age_days != null ? ` · колония ${hive.age_days} дн.` : ""}
{loading &&
Загрузка данных улья…
} {!loading && !hive && (
Не удалось загрузить данные улья
)} {!loading && !!hive && ( <>
Активность
{hive.activity_per_min ?? "—"} /мин
Возраст колонии
{hive.age_days ?? "—"} дн.
Статус
{String(hive.status || "ok").toUpperCase()}
{/* [TASK_START:T008] */} {hive?.gate_device_id && ( )} {/* [TASK_COMPLETE:T008] */} )}
); } window.HiveDetail = HiveDetail; window.HiveGateControl = HiveGateControl;