163 lines
9.2 KiB
TypeScript
163 lines
9.2 KiB
TypeScript
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/<id></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>
|
||
);
|
||
}
|