Files
egorin/src/modules/energy/LoraModule.tsx
T

163 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback } from 'react';
import { RadioTower, Cable, Wifi, Zap, Plus, Router, Waypoints } from 'lucide-react';
import { apiGet, apiPost } from '@/api/client';
import { cn } from '@/lib/utils';
import { LoraMeshViz } from './LoraMeshViz';
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>
{/* Визуализатор ретрансляции/маршрутизации LoRa-хопов */}
<div className="overflow-hidden rounded-2xl border border-border bg-surface">
<div className="flex items-center gap-2 border-b border-border px-3 py-2 text-[11px] uppercase tracking-wide text-fg-muted">
<Waypoints className="h-3.5 w-3.5" /> Маршрутизация LoRa · хопы и ретрансляция
</div>
<div className="h-[280px] w-full bg-[#070a10]">
<LoraMeshViz
gatewayLabel={gws[0]?.gw_id || 'gw1'}
nodes={devices.filter((d) => d.transport === 'lora' && d.status !== 'disabled').map((d) => ({ id: d.node_id, label: d.name || d.node_id, status: d.status, rssi: d.last_rssi, transport: d.transport }))}
/>
</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>
);
}