Files
OpsLog/frontend/src/components/StationControlPanel.tsx
T
2026-08-21 18:21:33 +02:00

792 lines
43 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 { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { MotorAntennaWidget, type AntStatus } from '@/components/MotorAntennaWidget';
import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { RotorCompass } from '@/components/RotorCompass';
import { AmpCard } from '@/components/AmpCard';
import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel';
import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
RotatorGoTo, RotatorStop, SetActiveRotor,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
GetAmpStatuses, GetFlexState,
GetTunerGeniusStatus, GetTunerGeniusSettings,
GetPSUStatus, GetPSUSettings, SetPSUOutput,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
type PSUState = { connected: boolean; on: boolean; volts: number; amps: number; watts: number; set_volts: number; set_amps: number; protected: number; error?: string };
// The bench supply. One button — the output — and the three numbers that say
// what it is actually delivering.
//
// The SET points are shown beside them, small, because "13.8 V set" next to
// "0.02 A out" is how an operator sees at a glance that the supply is on but
// the radio is not drawing. They are not editable: OpsLog reads them and never
// writes them, which is the whole safety story of this device.
function PSUCard({ st, busy, onToggle, t }: {
st: PSUState; busy: boolean; onToggle: (on: boolean) => void; t: (k: string, v?: any) => string;
}) {
const tripped = (st.protected ?? 0) !== 0;
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Power className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{t('psu.title')}</div>
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
title={st.connected ? t('station.online') : (st.error || t('psu.offline'))} />
</div>
<div className="p-3 space-y-2">
<div className="flex items-center gap-3">
<button type="button" disabled={!st.connected || busy}
onClick={() => onToggle(!st.on)}
className={cn('flex items-center gap-2 rounded-md border px-3 py-1.5 transition-colors disabled:opacity-40',
st.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
<span className={cn('flex items-center justify-center size-6 rounded shrink-0',
st.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
</span>
<span className="text-xs font-semibold">{t('psu.output')}</span>
<span className={cn('text-[10px] font-bold', st.on ? 'text-success' : 'text-muted-foreground/60')}>
{st.on ? t('station.on') : t('station.off')}
</span>
</button>
<div className="flex-1 min-w-0 font-mono tabular-nums text-right">
<span className="text-lg font-bold">{(st.volts ?? 0).toFixed(2)}</span><span className="text-xs text-muted-foreground"> V</span>
<span className="text-lg font-bold ml-3">{(st.amps ?? 0).toFixed(3)}</span><span className="text-xs text-muted-foreground"> A</span>
</div>
</div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground font-mono">
<span>{(st.watts ?? 0).toFixed(1)} W</span>
<span>{t('psu.setTo')} {(st.set_volts ?? 0).toFixed(2)} V / {(st.set_amps ?? 0).toFixed(3)} A</span>
</div>
{tripped && (
<div className="text-[11px] font-bold text-danger">{t('psu.tripped')} (0x{(st.protected ?? 0).toString(16).toUpperCase()})</div>
)}
</div>
</div>
);
}
type Device = {
id: string; type: string; name: string; host: string;
user?: string; pass?: string; channels?: number; labels: string[];
// Generic HTTP board only. The per-relay URLs win over the patterns.
on_urls?: string[]; off_urls?: string[]; on_pattern?: string; off_pattern?: string; insecure_tls?: boolean;
};
type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
// packed tighter but left the row edges ragged, which read as untidy on a wide
// screen; aligned rows are what an operator expects from a dashboard.
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
const GRID_GAP = 16; // px between cards, both axes
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2, httpgen: 4 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay', httpgen: 'HTTP relay' };
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
// the generic USB-serial board, whose channel count the user picks.
// chanCount is the relay count for a device. It MUST agree with
// deviceRelayCount in app.go: the two decide how many labels and URL rows the
// editor shows and how many relays the driver is built for, and a type known to
// one and not the other silently pins the count to its fixed default — which is
// exactly what happened when the HTTP board was added here and not below.
const chanCount = (d: Device): number =>
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8)
: d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
: d.type === 'httpgen' ? (d.channels && d.channels >= 1 ? d.channels : 4)
: (RELAY_COUNT[d.type] ?? 5);
function blankDevice(): Device {
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
}
type Heading = { enabled: boolean; ok: boolean; azimuth: number; rotors?: string[]; active?: number };
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
// Station Control tab: a live compass you can click to turn, a heading readout, a
// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and
// passed in (so the panel can also order this widget); it drives the shared
// rotator backend.
function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & {
hd: Heading; refetch: () => void; t: (k: string, v?: any) => string;
}) {
const [goto, setGoto] = useState('');
const [err, setErr] = useState('');
const turn = (az: number) => {
const a = ((Math.round(az) % 360) + 360) % 360;
RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e)));
};
const poll = refetch;
const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]];
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Compass className="size-4 text-primary" />
<div className="text-sm font-semibold">{t('station.rotator')}</div>
<span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
</div>
<div className="p-3 flex gap-4 items-start">
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
app carries it rather than each caller drawing its own. */}
<RotorCompass
bearing={bearing ?? null}
headings={hd.ok ? [hd.azimuth] : []}
centerLat={centerLat ?? null}
centerLon={centerLon ?? null}
rotorEnabled={hd.ok}
rotors={hd.rotors}
activeRotor={hd.active}
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
onGoto={(az) => turn(az)}
/>
<div className="flex-1 min-w-0 space-y-2">
<div className="font-mono">
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
</div>
<div className="flex gap-1">
{presets.map(([lbl, az]) => (
<button key={lbl} type="button" onClick={() => turn(az)}
className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
{lbl}
</button>
))}
</div>
<div className="flex gap-1.5">
<Input value={goto} onChange={(e) => setGoto(e.target.value.replace(/[^0-9]/g, ''))}
onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }}
placeholder="0359" className="h-8 flex-1 min-w-0 font-mono text-sm" />
<Button size="sm" className="h-8 shrink-0" disabled={!goto} onClick={() => turn(parseInt(goto, 10))}>{t('station.go')}</Button>
</div>
<Button size="sm" variant="outline" className="h-8 w-full" onClick={() => RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
<Square className="size-3 mr-1" /> {t('station.stop')}
</Button>
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
</div>
</div>
</div>
);
}
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
const { t } = useI18n();
const [devices, setDevices] = useState<Device[]>([]);
const [status, setStatus] = useState<Record<string, DevStatus>>({});
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
const [ant, setAnt] = useState<AntStatus>({ enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [] });
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
const [order, setOrder] = useState<string[]>(() => {
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
});
const dragId = useRef<string | null>(null);
// Max columns per row (persisted). "Auto" fills the window; a number caps the
// row so cards wrap onto further lines even when there's horizontal room.
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
// cards as the window allows; an explicit count is honoured as asked, even
// past that width — the operator can see his own screen.
const gridRef = useRef<HTMLDivElement | null>(null);
const [colCount, setColCount] = useState(1);
useLayoutEffect(() => {
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
const el = gridRef.current;
if (!el) return;
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [cols]);
// Amplifiers (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. EVERY configured amp gets its own
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
// FlexRadio meters read live; the Flex state rides along because a PGXL's
// OPERATE/meters come from the radio, not the direct GSCP link.
const [amps, setAmps] = useState<any[]>([]);
const [flexState, setFlexState] = useState<any>(null);
useEffect(() => {
let alive = true;
const load = () => Promise.all([
GetAmpStatuses().catch(() => []),
GetFlexState().catch(() => null),
]).then(([l, fx]: any[]) => { if (alive) { setAmps((l ?? []) as any[]); setFlexState(fx); } });
load();
const id = window.setInterval(load, 1500);
return () => { alive = false; window.clearInterval(id); };
}, []);
// Tuner Genius XL (4O3A): a card here too, so operators without a FlexRadio
// panel still get the controls. Re-read the enabled flag so it appears/hides
// without a restart.
const [tg, setTg] = useState<TGStatus>({ connected: false });
const [tgEnabled, setTgEnabled] = useState(false);
useEffect(() => {
let alive = true;
const load = async () => {
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
};
load();
const id = window.setInterval(load, 500); // fast so meters track TX (see App.tsx)
return () => { alive = false; window.clearInterval(id); };
}, []);
// Bench power supply. Polled slower than the tuner: it has no meters that
// track a transmission, and every poll is three Modbus exchanges on a 9600
// baud line the operator may also be using to switch the output.
const [psu, setPsu] = useState<PSUState>({ connected: false, on: false, volts: 0, amps: 0, watts: 0, set_volts: 0, set_amps: 0, protected: 0 });
const [psuEnabled, setPsuEnabled] = useState(false);
const [psuBusy, setPsuBusy] = useState(false);
useEffect(() => {
let alive = true;
const load = async () => {
try { const en: any = await GetPSUSettings(); if (alive) setPsuEnabled(!!en?.enabled); } catch {}
try { const st: any = await GetPSUStatus(); if (alive && st) setPsu(st as PSUState); } catch {}
};
load();
const id = window.setInterval(load, 1500);
return () => { alive = false; window.clearInterval(id); };
}, []);
const togglePSU = useCallback(async (on: boolean) => {
setPsuBusy(true);
// No optimistic flip here, unlike the relays: the supply echoes the value it
// actually set, so showing ON before it confirms would be showing something
// OpsLog does not know. A power switch is the wrong place to guess.
try { await SetPSUOutput(on); const st: any = await GetPSUStatus(); if (st) setPsu(st as PSUState); }
catch { /* the poll will tell the truth */ }
finally { setPsuBusy(false); }
}, []);
const loadDevices = useCallback(async () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
}, []);
const poll = useCallback(async () => {
try {
const s = ((await GetStationStatus()) ?? []) as DevStatus[];
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
} catch { /* ignore transient */ }
}, []);
const pollAnt = useCallback(async () => {
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
}, []);
useEffect(() => { loadDevices(); }, [loadDevices]);
// The heading comes from the shared poller: one loop for the whole app, fast
// while the antenna turns and slow while it is parked. Every poll opens and
// closes the controller's port, and this panel used to run its own alongside
// the status bar's — the same controller asked twice over.
useEffect(() => subscribeRotorHeading((h) => setRot(h as any)), []);
useEffect(() => {
poll(); pollAnt();
const id = window.setInterval(() => { poll(); pollAnt(); }, 3000);
return () => window.clearInterval(id);
}, [poll, pollAnt, devices.length]);
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
// Reorder so `dragged` lands just before `target`.
const onDrop = (targetId: string) => {
const src = dragId.current; dragId.current = null;
if (!src || src === targetId) return;
const ids = widgetIds.filter((id) => id !== src);
const at = ids.indexOf(targetId);
ids.splice(at < 0 ? ids.length : at, 0, src);
persistOrder(ids);
};
const persist = async (next: Device[]) => {
setDevices(next);
try { await SaveStationDevices(next as any); } catch { /* surfaced by status */ }
await loadDevices();
poll();
};
const toggle = async (dev: Device, relay: number, on: boolean) => {
const key = `${dev.id}:${relay}`;
setBusy((b) => ({ ...b, [key]: true }));
// Optimistic flip so the switch feels instant; the poll reconciles.
setStatus((st) => {
const d = st[dev.id]; if (!d) return st;
return { ...st, [dev.id]: { ...d, relays: d.relays.map((r) => (r.number === relay ? { ...r, on } : r)) } };
});
try { await StationSetRelay(dev.id, relay, on); } catch { /* poll will correct */ }
await poll();
setBusy((b) => ({ ...b, [key]: false }));
};
const saveEdit = async () => {
if (!editing) return;
const d = { ...editing, name: editing.name.trim() || TYPE_LABEL[editing.type], host: editing.host.trim() };
const exists = devices.some((x) => x.id && x.id === d.id);
await persist(exists ? devices.map((x) => (x.id === d.id ? d : x)) : [...devices, d]);
setEditing(null);
};
const removeDevice = async (id: string) => { await persist(devices.filter((x) => x.id !== id)); };
// Build the widget list (rotator first by default, then devices), then order it
// by the saved drag order — unknown ids fall to the end in their natural order.
const deviceCard = (dev: Device) => {
const st = status[dev.id];
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
// The generic HTTP board has no address of its own and nothing to poll: its
// relays can each live on a different box, and no status endpoint is read
// back. So no host under the name, no online dot, and the buttons are never
// greyed out waiting for a connection that is never made.
const fireAndForget = dev.type === 'httpgen';
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<PlugZap className="size-4 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
<div className="text-[10px] text-muted-foreground font-mono truncate">
{TYPE_LABEL[dev.type]}{fireAndForget || !dev.host ? '' : ` · ${dev.host}`}
</div>
</div>
{fireAndForget ? <span className="ml-auto" /> : (
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
)}
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
</div>
{/* Compact one-line relay buttons at a FIXED width so they don't stretch
across the whole card — they wrap to fill the available width instead. */}
<div className="p-2 flex flex-wrap gap-1.5">
{relays.map((r) => {
const key = `${dev.id}:${r.number}`;
const label = r.label || `${t('station.relay')} ${r.number}`;
return (
<button key={r.number} type="button" disabled={!fireAndForget && !st?.connected}
title={label}
onClick={() => toggle(dev, r.number, !r.on)}
className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
<span className={cn('flex items-center justify-center size-5 rounded shrink-0',
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
{busy[key] ? <Loader2 className="size-3 animate-spin" /> : <Power className="size-3" />}
</span>
<span className="flex-1 min-w-0 text-xs font-medium truncate">{label}</span>
<span className={cn('text-[9px] font-bold shrink-0', r.on ? 'text-success' : 'text-muted-foreground/50')}>
{r.on ? t('station.on') : t('station.off')}
</span>
</button>
);
})}
</div>
</div>
);
};
// `wide` cards take two grid columns instead of one. The amplifier and tuner
// carry meter rows and a channel selector that are unreadable squeezed into a
// single ~430px column — they are the same cards the FlexRadio panel shows
// full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
}
if (ant.enabled) {
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
}
// One card per configured amplifier (identical to the Flex panel's card).
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
// Tuner Genius XL card (identical to the Flex panel's).
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
if (psuEnabled) widgets.push({ id: 'psu', node: <PSUCard st={psu} busy={psuBusy} onToggle={togglePSU} t={t} /> });
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
const widgetIds = ordered.map((w) => w.id);
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0 && !tgEnabled;
return (
<div className="flex-1 min-h-0 overflow-auto p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
<div className="flex items-center gap-2">
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
card can sit on a further line even with horizontal room to spare. */}
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
<button key={c} type="button"
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
{c === 'auto' ? t('station.colsAuto') : c}
</button>
))}
</div>
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
</Button>
</div>
</div>
{noDevices && !editing && (
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
{t('station.empty')}
</div>
)}
{/* Dashboard grid: equal columns, and every card stretches to the height of
the tallest one on its row, so the rows line up instead of ending
ragged. One column each, two for the amplifier/tuner whose meter rows
need the room. Each card has a grip handle (left rail) as the drag
initiator, since the body is full of buttons. */}
<div ref={gridRef} style={{
display: 'grid',
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
gap: `${GRID_GAP}px`,
}}>
{ordered.map((w) => (
<div key={w.id}
// A wide card takes two columns — but never more than there are, or
// the grid would grow a phantom column on a narrow window.
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
{/* h-full down the chain is what makes the card fill the row height
rather than sit at its natural size in a taller cell. */}
<div className="flex items-stretch h-full">
<div draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragEnd={() => { dragId.current = null; }}
title={t('station.dragMove')}
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
dragId.current === w.id && 'opacity-60')}>
<GripVertical className="size-4" />
</div>
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
</div>
</div>
))}
</div>
{!noDevices && (
<p className="text-[11px] text-muted-foreground mt-2">{t('station.dragHint')}</p>
)}
{editing && (
<DeviceEditor device={editing} onChange={setEditing} onSave={saveEdit} onCancel={() => setEditing(null)} t={t} />
)}
</div>
);
}
function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
device: Device; onChange: (d: Device) => void; onSave: () => void; onCancel: () => void; t: (k: string, v?: any) => string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
const setType = (type: string) => {
const channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
const n = chanCount({ ...device, type, channels });
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
onChange({ ...device, type, channels, labels });
};
const setChannels = (channels: number) => {
const n = chanCount({ ...device, channels });
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
onChange({ ...device, channels, labels });
};
const isKM = device.type === 'kmtronic';
const isDingtian = device.type === 'dingtian';
const isDenkovi = device.type === 'denkovi';
const isUsbRelay = device.type === 'usbrelay';
const isHTTPGen = device.type === 'httpgen';
// {value} sends a relay's label, so a URL using it on an unnamed relay would
// go out with an empty parameter. Warn while it is being typed rather than at
// the moment an antenna fails to switch.
const valueNeedsLabels = isHTTPGen
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
.some((s) => (s ?? '').includes('{value}'))
&& device.labels.some((l) => !l.trim());
// Any https:// among this board's URLs. A relay box on the LAN signs its own
// certificate, so HTTPS to one cannot be verified — the operator has to say
// whether to accept that, and the question only arises once they type https.
const usesHTTPS = isHTTPGen
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
.some((u) => (u ?? '').trim().toLowerCase().startsWith('https://'));
// COM ports for the generic USB-serial relay picker.
const [serialPorts, setSerialPorts] = useState<string[]>([]);
useEffect(() => {
if (!isUsbRelay) return;
ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
}, [isUsbRelay]);
// Detected FTDI serials for the Denkovi picker.
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
const [detecting, setDetecting] = useState(false);
const [detectMsg, setDetectMsg] = useState('');
const detectDenkovi = async () => {
setDetecting(true);
setDetectMsg('');
try {
const list = ((await ListDenkoviDevices()) ?? []) as string[];
setDenkoviSerials(list);
// Auto-fill when there's exactly one and nothing chosen yet.
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length }));
} catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); }
finally { setDetecting(false); }
};
// Connection test result for the current device config.
const [testing, setTesting] = useState(false);
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
const testDevice = async () => {
setTesting(true);
setTestMsg(null);
try {
const r: any = await TestStationDevice(device as any);
setTestMsg(r?.ok
? { ok: true, text: t('station.testOk', { n: r.relays }) }
: { ok: false, text: r?.error || t('station.testFail') });
} catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); }
finally { setTesting(false); }
};
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
return (
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('station.type')}</Label>
<Select value={device.type} onValueChange={setType}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
<SelectItem value="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
<SelectItem value="httpgen">{t('station.typeHttpGen')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>{t('station.name')}</Label>
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
</div>
</div>
{(isDenkovi || isUsbRelay || isDingtian || isHTTPGen) && (
<div className="space-y-1 max-w-[10rem]">
<Label>{t('station.channels')}</Label>
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{(isDenkovi ? [4, 8] : isDingtian ? [2, 4, 8, 16, 24, 32] : [1, 2, 4, 8, 16]).map((n) => (
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isDenkovi ? (
<div className="space-y-1 max-w-md">
<Label>{t('station.ftdiSerial')}</Label>
<div className="flex items-center gap-2">
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
list="denkovi-serials"
onChange={(e) => onChange({ ...device, host: e.target.value })} />
<datalist id="denkovi-serials">
{denkoviSerials.map((s) => <option key={s} value={s} />)}
</datalist>
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
{t('station.detect')}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
{detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
</div>
) : isUsbRelay ? (
<div className="space-y-1 max-w-md">
<Label>{t('station.comPort')}</Label>
<div className="flex items-center gap-2">
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
<RefreshCw className="size-3.5" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
</div>
) : isHTTPGen ? null : (
/* No Host for the generic board: its driver never reads one. Each URL
below carries its own address, and they need not even share it — one
relay can sit on a different box from the next. A field that changes
nothing is worse than no field: it reads as the thing to fill in first,
and then the URLs look like they should be relative to it. */
<div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
<Label>{t('station.host')}</Label>
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
<span className="text-[10px] text-muted-foreground">{t('station.hostHint')}</span>
</div>
{/* Dingtian: both are OFF on a factory board — the session ID only when
"HTTP Session" is enabled in its web page, the password only when a
relay password is set. */}
{isDingtian && (
<>
<div className="space-y-1">
<Label>{t('station.dtSession')}</Label>
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
onChange={(e) => onChange({ ...device, user: e.target.value })} />
</div>
<div className="space-y-1">
<Label>{t('station.dtPwd')}</Label>
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
</div>
</>
)}
{isKM && (
<>
<div className="space-y-1">
<Label>{t('station.user')}</Label>
<Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
</div>
<div className="space-y-1">
<Label>{t('station.pass')}</Label>
<Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
</div>
</>
)}
</div>
)}
{/* Generic HTTP board: a URL to switch each relay on and one to switch it
off. Two ways to fill it, and the per-relay one is why this type
exists — a hand-made switch often has URLs with nothing in common
between channels, which no pattern can express. */}
{isHTTPGen && (
<div className="space-y-3 rounded-md border border-border/60 p-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('station.onPattern')}</Label>
<Input className="font-mono text-xs" placeholder="http://192.168.1.9/relay?n={relay}&state=on"
value={device.on_pattern ?? ''}
onChange={(e) => onChange({ ...device, on_pattern: e.target.value })} />
</div>
<div className="space-y-1">
<Label>{t('station.offPattern')}</Label>
<Input className="font-mono text-xs" placeholder="http://192.168.1.9/relay?n={relay}&state=off"
value={device.off_pattern ?? ''}
onChange={(e) => onChange({ ...device, off_pattern: e.target.value })} />
</div>
</div>
<div className="text-[10px] text-muted-foreground">{t('station.patternHint')}</div>
{/* Shown only once an https:// URL is actually in use. A board on
plain HTTP has no certificate to argue about, and an option that
cannot matter yet is one more thing to wonder about. */}
{usesHTTPS && (
<label className="flex items-start gap-2 text-xs cursor-pointer">
<Checkbox className="mt-0.5" checked={!!device.insecure_tls}
onCheckedChange={(c) => onChange({ ...device, insecure_tls: !!c })} />
<span>
{t('station.insecureTls')}
<span className="block text-[10px] text-muted-foreground">{t('station.insecureTlsHint')}</span>
</span>
</label>
)}
<div className="space-y-1">
<Label>{t('station.perRelayUrls')}</Label>
<div className="space-y-1">
{device.labels.map((_, i) => (
<div key={i} className="grid grid-cols-[2.5rem_1fr_1fr] items-center gap-2">
<span className="text-[11px] text-muted-foreground">{i + 1}</span>
<Input className="h-8 font-mono text-[11px]" placeholder={t('station.onUrlPh')}
value={device.on_urls?.[i] ?? ''}
onChange={(e) => {
const on_urls = [...(device.on_urls ?? [])];
while (on_urls.length < device.labels.length) on_urls.push('');
on_urls[i] = e.target.value;
onChange({ ...device, on_urls });
}} />
<Input className="h-8 font-mono text-[11px]" placeholder={t('station.offUrlPh')}
value={device.off_urls?.[i] ?? ''}
onChange={(e) => {
const off_urls = [...(device.off_urls ?? [])];
while (off_urls.length < device.labels.length) off_urls.push('');
off_urls[i] = e.target.value;
onChange({ ...device, off_urls });
}} />
</div>
))}
</div>
<div className="text-[10px] text-muted-foreground">{t('station.perRelayHint')}</div>
</div>
</div>
)}
<div className="space-y-1">
<Label>{t('station.labels')}</Label>
{/* {value} sends the label, so an unnamed relay would go out as "?on=".
Said here, beside the empty box, rather than when the antenna fails
to switch and the log is the only place that explains why. */}
{valueNeedsLabels && <p className="text-[10px] text-warning">{t('station.valueNeedsLabels')}</p>}
<div className="grid grid-cols-4 gap-2">
{device.labels.map((lab, i) => (
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
))}
</div>
</div>
<div className="flex items-center gap-2">
{testMsg && (
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
{testMsg.text}
</span>
)}
<div className="ml-auto flex gap-2">
{/* No connection test for the generic board, and no host required to
save it. There is nothing to test: it has no address of its own and
no status to read — its URLs are fired and forgotten. A button that
can only ever say "OK, 4 relays" tests nothing, and a Save greyed
out for a missing host made a perfectly complete configuration —
four full URLs — impossible to store. */}
{!isHTTPGen && (
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
{t('station.test')}
</Button>
)}
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
<Button size="sm" onClick={onSave} disabled={!isHTTPGen && !device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
</div>
</div>
</div>
);
}