egorin: LoRa/LAN энергомониторинг — кит в /lora (прошивки+schema+provisioner+UI), бэкенд ingest от шлюза-концентратора + реестр устройств, фронт-раздел (карточки/реле/pending/шлюзы)

This commit is contained in:
german
2026-07-09 23:49:34 +03:00
parent 27067d3d12
commit fc10cb77ba
12 changed files with 1544 additions and 2 deletions
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useState, useCallback } from 'react';
import { RadioTower, Cable, Wifi, Zap, Plus, Router } from 'lucide-react';
import { apiGet, apiPost } from '@/api/client';
import { cn } from '@/lib/utils';
interface LoraDevice {
node_id: string; name?: string | null; project?: string | null; object?: string | null;
transport: 'lora' | 'lan'; status: 'pending' | 'active' | 'disabled';
caps?: { pzem?: boolean; relays?: number; ctl?: boolean }; relay_names?: string[];
fw?: string; ip?: string; gw?: string; last_seen?: string; last_rssi?: number; online?: boolean;
v?: number; a?: number; w?: number; kwh?: number; pf?: number; hz?: number; relays?: number[];
}
interface Gateway { gw_id: string; name: string; ip: string; online: boolean; nodes: number; avg_rssi: number }
const lastSeen = (iso?: string) => {
if (!iso) return '—';
const s = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
return s < 60 ? `${s} с назад` : s < 3600 ? `${Math.round(s / 60)} мин назад` : `${Math.round(s / 3600)} ч назад`;
};
export function LoraModule() {
const [devices, setDevices] = useState<LoraDevice[]>([]);
const [gws, setGws] = useState<Gateway[]>([]);
const [filter, setFilter] = useState<'all' | 'egorin' | 'orbit'>('all');
const load = useCallback(async () => {
try {
const [d, g] = await Promise.all([apiGet<LoraDevice[]>('/lora/devices'), apiGet<Gateway[]>('/lora/gateways')]);
setDevices(d); setGws(g);
} catch { /* ignore */ }
}, []);
useEffect(() => { load(); const t = setInterval(load, 3000); return () => clearInterval(t); }, [load]);
const pending = devices.filter((d) => d.status === 'pending');
const active = devices.filter((d) => d.status === 'active' && (filter === 'all' || d.project === filter));
const approve = async (id: string) => { await apiPost(`/lora/devices/${id}/approve`, { name: id, project: 'egorin' }); load(); };
const toggleRelay = async (id: string, n: number, cur: number) => { await apiPost(`/lora/devices/${id}/cmd`, { cmd: 'relay', n, s: cur ? 0 : 1 }); load(); };
const sumKwh = active.reduce((s, d) => s + (d.kwh || 0), 0);
const sumW = active.reduce((s, d) => s + (d.w || 0), 0);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="grid h-9 w-9 place-items-center rounded-xl bg-accent/15 text-accent"><RadioTower className="h-5 w-5" /></span>
<div>
<div className="text-base font-semibold leading-tight">Энерго LoRa/LAN</div>
<div className="text-[11px] text-fg-muted">датчики PZEM → шлюз-концентратор → сервер · MQTT-топики nodes/&lt;id&gt;</div>
</div>
</div>
<div className="flex items-center gap-3 text-sm">
<span className="rounded-lg bg-muted px-2.5 py-1"><b className="tnum">{active.length}</b> узлов</span>
<span className="rounded-lg bg-muted px-2.5 py-1">Σ <b className="tnum">{(sumW / 1000).toFixed(1)}</b> кВт</span>
<span className="rounded-lg bg-muted px-2.5 py-1">Σ <b className="tnum">{sumKwh.toFixed(1)}</b> кВт·ч</span>
</div>
</div>
{/* Шлюзы */}
<div className="flex flex-wrap gap-2">
{gws.map((g) => (
<div key={g.gw_id} className="flex items-center gap-2 rounded-xl border border-border bg-surface px-3 py-2 text-xs">
<Router className={cn('h-4 w-4', g.online ? 'text-ok' : 'text-fg-muted')} />
<span className="font-semibold">{g.gw_id}</span>
<span className="text-fg-muted">{g.ip} · {g.nodes} узлов · RSSI ~{g.avg_rssi} дБм</span>
</div>
))}
</div>
{/* Обнаружены новые устройства (pending) */}
{pending.length > 0 && (
<div className="rounded-2xl border border-warn/40 bg-warn/10 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-warn"><Plus className="h-4 w-4" /> Обнаружены новые устройства ({pending.length})</div>
<div className="flex flex-col gap-2">
{pending.map((d) => (
<div key={d.node_id} className="flex items-center justify-between gap-2 rounded-xl bg-surface/60 px-3 py-2 text-sm">
<div className="flex items-center gap-2">
<span className="font-mono font-semibold">{d.node_id}</span>
<Badge d={d} />
<span className="text-[11px] text-fg-muted">реле: {d.caps?.relays ?? 0} · {lastSeen(d.last_seen)}</span>
</div>
<button onClick={() => approve(d.node_id)} className="rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-[#06210f] hover:opacity-90">Добавить</button>
</div>
))}
</div>
</div>
)}
{/* Фильтр-чипы */}
<div className="flex gap-2">
{(['all', 'egorin', 'orbit'] as const).map((f) => (
<button key={f} onClick={() => setFilter(f)} className={cn('rounded-full px-3 py-1 text-xs font-semibold', filter === f ? 'bg-accent text-[#06210f]' : 'border border-border text-fg-muted hover:bg-muted')}>
{f === 'all' ? 'Все' : f === 'egorin' ? 'Егорин' : 'ORBIT'}
</button>
))}
</div>
{/* Карточки узлов */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{active.map((d) => (
<div key={d.node_id} className="rounded-2xl border border-border bg-surface p-4">
<div className="flex items-start justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn('h-2.5 w-2.5 rounded-full', d.online ? 'animate-pulse bg-ok' : 'bg-fg-muted')} />
<span className="truncate font-semibold">{d.name || d.node_id}</span>
</div>
<div className="truncate text-[11px] text-fg-muted">{d.object || '—'} · {d.node_id}</div>
</div>
<Badge d={d} />
</div>
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
{[['V', d.v, 'В'], ['A', d.a, 'А'], ['W', d.w, 'Вт'], ['kWh', d.kwh, 'кВт·ч']].map(([k, val, u]) => (
<div key={k as string} className="rounded-lg bg-muted/60 py-1.5">
<div className="tnum text-sm font-semibold">{val != null ? Number(val).toFixed(k === 'A' ? 2 : k === 'kWh' ? 1 : 0) : '—'}</div>
<div className="text-[9px] text-fg-muted">{u as string}</div>
</div>
))}
</div>
{(d.relays?.length ?? 0) > 0 && (
<div className="mt-3 flex flex-col gap-1.5">
{d.relays!.map((r, n) => (
<button key={n} onClick={() => toggleRelay(d.node_id, n, r)} className={cn('flex items-center justify-between rounded-lg border px-2.5 py-1.5 text-xs transition-colors', r ? 'border-ok/40 bg-ok/10 text-ok' : 'border-border text-fg-muted hover:bg-muted')}>
<span className="flex items-center gap-1.5"><Zap className="h-3 w-3" /> {d.relay_names?.[n] || `Реле ${n + 1}`}</span>
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-semibold', r ? 'bg-ok/20' : 'bg-muted')}>{r ? 'ВКЛ' : 'выкл'}</span>
</button>
))}
</div>
)}
<div className="mt-2 text-[10px] text-fg-muted">{lastSeen(d.last_seen)}{d.transport === 'lora' && d.last_rssi != null ? ` · RSSI ${d.last_rssi} дБм` : ''}{d.ip ? ` · ${d.ip}` : ''}</div>
</div>
))}
</div>
{active.length === 0 && <div className="py-8 text-center text-sm text-fg-muted">Нет активных узлов в этом фильтре.</div>}
</div>
);
}
function Badge({ d }: { d: LoraDevice }) {
const lora = d.transport === 'lora';
return (
<span className={cn('flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-semibold', lora ? 'bg-info/15 text-info' : 'bg-accent/15 text-accent')}>
{lora ? <Wifi className="h-3 w-3" /> : <Cable className="h-3 w-3" />}
{lora ? 'LoRa' : 'LAN'}
</span>
);
}