// 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 ? (
Команды ещё не отправлялись в текущей сессии
) : (
| ID |
Действие |
Кем |
Время |
Статус |
{history.map((row) => (
| {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;