feat: Station Control amp parity + all amps, Help→Send log to F4BPO, bulk-edit frequency, fix Cluster midnight sort
- Station Control: the amplifier panel is now the exact same card as the FlexRadio panel (extracted shared AmpCard: OPERATE/STANDBY, ON/OFF, power level, output-power bar, live FWD/ID/temp meters). Every configured amp gets its own card — no dropdown. - Help → "Send log to F4BPO" (only when SMTP is configured): e-mails opslog.log + previous session + crash log to the developer. New email.SendFiles for multi-attachment. - Bulk edit: new "Frequency (MHz)" field sets freq_hz AND recomputes band; out-of-band values rejected. Fixes a run logged on a stale/default freq after CAT dropped. - Cluster: Time column sorted lexically on the HHMMZ string, so spots after 0000Z sorted below 2359Z and looked frozen at the UTC rollover. Now sorts by real arrival time. - Also folds in the DVK auto-CQ/2-column layout and Station Control dashboard batch (changelog 0.20.10, EN+FR).
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame, GripVertical } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -8,12 +8,13 @@ import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { AmpCard } from '@/components/AmpCard';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, GetFlexState, FlexAmpOperate,
|
||||
GetAmpStatuses, GetFlexState,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
@@ -249,171 +250,6 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
||||
);
|
||||
}
|
||||
|
||||
// AmplifierWidget brings the amplifier controls (Settings → Amplifier) into the
|
||||
// Station Control tab, so an operator WITHOUT a FlexRadio/Icom panel still has
|
||||
// them. Several amps can be configured (some ops run two SPEs in parallel) —
|
||||
// the header dropdown picks which one this card shows; the choice is remembered.
|
||||
function AmplifierWidget({ t }: { t: (k: string, v?: any) => string }) {
|
||||
const [list, setList] = useState<any[]>([]);
|
||||
const [sel, setSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.station') || '');
|
||||
const [flex, setFlex] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
// The Flex state rides along because a PGXL's OPERATE state comes from the
|
||||
// radio (the direct GSCP status doesn't carry it).
|
||||
const tick = () => Promise.all([
|
||||
GetAmpStatuses().catch(() => []),
|
||||
GetFlexState().catch(() => null),
|
||||
]).then(([l, fx]: any[]) => { if (alive) { setList((l ?? []) as any[]); setFlex(fx); } });
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
const amp = list.find((a) => a.id === sel) ?? list[0];
|
||||
if (!amp) return null;
|
||||
const isACOM = !!amp.acom;
|
||||
const isSPE = !!amp.spe;
|
||||
const isPGXL = !isACOM && !isSPE;
|
||||
const viaFlex = isPGXL && !!flex?.amp_available;
|
||||
const raw = amp.spe ?? amp.acom ?? amp.pgxl ?? { connected: false };
|
||||
const st: any = isPGXL
|
||||
? { ...raw, connected: !!raw.connected || viaFlex, operate: viaFlex ? !!flex.amp_operate : !!raw.operate }
|
||||
: raw;
|
||||
const doOperate = () => {
|
||||
const want = !st.operate;
|
||||
(isPGXL && viaFlex ? FlexAmpOperate(want) : AmpOperate(amp.id, want)).catch(() => {});
|
||||
};
|
||||
const maxW = isACOM ? (Number(st.max_w) || 800) : ({ '13K': 1300, '15K': 1500, '2K': 2000 } as Record<string, number>)[st.model] || 1500;
|
||||
const outW = Number(isACOM ? st.fwd_w : st.output_w) || 0;
|
||||
const frac = Math.min(1, outW / maxW);
|
||||
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">
|
||||
<Flame className="size-4 text-primary" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{t('flxp.amplifier')}</div>
|
||||
{list.length <= 1 && <div className="text-[10px] text-muted-foreground font-mono truncate">{amp.name}</div>}
|
||||
</div>
|
||||
{list.length > 1 && (
|
||||
<select value={amp.id} title={t('flxp.ampPick')}
|
||||
onChange={(e) => { setSel(e.target.value); localStorage.setItem('opslog.ampSel.station', e.target.value); }}
|
||||
className="h-7 rounded-md border border-border bg-card px-1.5 text-xs font-semibold outline-none min-w-0">
|
||||
{list.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<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.last_error || t('station.offline'))} />
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
{isPGXL ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button type="button" disabled={!st.connected}
|
||||
onClick={doOperate}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
|
||||
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{st.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => (
|
||||
<button key={m} type="button" disabled={!st.connected}
|
||||
onClick={() => AmpFanMode(amp.id, m).catch(() => {})}
|
||||
className={cn('px-2.5 py-1.5 rounded-md border text-xs font-semibold disabled:opacity-40',
|
||||
st.fan_mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||
{m === 'STANDARD' ? t('flxp.fanStandard') : m === 'CONTEST' ? t('flxp.fanContest') : t('flxp.fanBroadcast')}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-xs text-muted-foreground font-mono">{st.state || ''}{st.temperature ? ` · ${Math.round(st.temperature)}°C` : ''}</span>
|
||||
</div>
|
||||
{/* Live amp meters (FWD / ID / TEMP …) from the FlexRadio UDP stream, so
|
||||
this card matches the amplifier card in the Flex panel. */}
|
||||
{viaFlex && (() => {
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const ampMeters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP') && !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (ampMeters.length === 0) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 pt-1">
|
||||
{ampMeters.map((m) => {
|
||||
const isPwr = /fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '');
|
||||
const val = isPwr ? dbmToW(m.value) : m.value;
|
||||
const unit = isPwr ? 'W' : (m.unit || '');
|
||||
const hi = isPwr ? 2000 : (/amp/i.test(m.unit || '') || /^ID$/i.test((m.name || '').trim())) ? 25 : (m.hi > 0 ? m.hi : 100);
|
||||
const frac = Math.min(1, Math.max(0, val / hi));
|
||||
const acc = isPwr ? '#dc2626' : /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||
return (
|
||||
<div key={m.id}>
|
||||
<div className="flex items-baseline justify-between text-[10px]">
|
||||
<span className="text-muted-foreground uppercase tracking-wider truncate">{m.name}</span>
|
||||
<span className="font-mono font-bold tabular-nums text-foreground/90">{val.toFixed(isPwr ? 0 : 1)}<span className="text-muted-foreground ml-0.5">{unit}</span></span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded bg-muted overflow-hidden mt-0.5">
|
||||
<div className="h-full transition-all" style={{ width: `${frac * 100}%`, background: acc }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button type="button" disabled={!st.connected}
|
||||
onClick={doOperate}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
|
||||
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{st.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<div className="inline-flex rounded-lg overflow-hidden border border-success/70">
|
||||
<button type="button"
|
||||
disabled={isACOM ? !(st.port_open && st.transport === 'serial') : !st.connected}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
className="px-2.5 py-1.5 text-xs font-bold bg-card text-success hover:bg-success/15 disabled:opacity-40">ON</button>
|
||||
<button type="button" disabled={!st.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="px-2.5 py-1.5 text-xs font-bold bg-card text-danger border-l border-success/70 hover:bg-danger/15 disabled:opacity-40">OFF</button>
|
||||
</div>
|
||||
{!isACOM && (
|
||||
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
||||
{(['L', 'M', 'H'] as const).map((lvl, i) => (
|
||||
<button key={lvl} type="button" disabled={!st.connected}
|
||||
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||
className={cn('px-2.5 py-1.5 text-xs font-bold disabled:opacity-40', i > 0 && 'border-l border-border',
|
||||
(st.power_level || '').trim().toUpperCase() === lvl ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-mono text-muted-foreground tabular-nums">
|
||||
{st.connected
|
||||
? <>
|
||||
{isACOM ? (st.state || '') : (st.tx ? 'TX' : 'RX')}
|
||||
{st.band ? ` · ${st.band}` : ''} · {outW}W · SWR {Number((isACOM ? st.swr : st.swr_ant) ?? 0).toFixed(1)} · {st.temp_c}°C
|
||||
{isACOM && st.fan ? ` · Fan ${st.fan}` : ''}
|
||||
</>
|
||||
: (isACOM ? t('flxp.acomOffline') : t('flxp.speOffline'))}
|
||||
</div>
|
||||
{st.connected && (
|
||||
<div>
|
||||
<div className="h-2 rounded bg-muted overflow-hidden">
|
||||
<div className={cn('h-full transition-all', frac > 0.9 ? 'bg-danger' : frac > 0.75 ? 'bg-warning' : 'bg-primary')}
|
||||
style={{ width: `${Math.round(frac * 100)}%` }} />
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">{t('flxp.outputPower')} — {outW} W / {maxW} W</div>
|
||||
</div>
|
||||
)}
|
||||
{(st.err_text || st.warnings || st.alarms) && (
|
||||
<div className="text-[11px] font-bold text-danger">⚠ {st.err_text || `${st.warnings || ''} ${st.alarms || ''}`}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||
const { t } = useI18n();
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
@@ -427,15 +263,24 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
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');
|
||||
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||
// FlexRadio panel still get the controls. Re-read every 5s so enabling an
|
||||
// amp in Settings makes the widget appear without reopening the tab.
|
||||
const [ampCount, setAmpCount] = useState(0);
|
||||
// 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 = () => GetAmpStatuses().then((l: any) => { if (alive) setAmpCount((l ?? []).length); }).catch(() => {});
|
||||
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, 5000);
|
||||
const id = window.setInterval(load, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
@@ -559,24 +404,36 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
if (ant.enabled) {
|
||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||
}
|
||||
if (ampCount > 0) {
|
||||
widgets.push({ id: 'amplifier', node: <AmplifierWidget 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} /> });
|
||||
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 && ampCount === 0;
|
||||
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0;
|
||||
|
||||
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>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||||
</Button>
|
||||
<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'] 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 && (
|
||||
@@ -585,11 +442,12 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dashboard of FIXED-WIDTH cards that wrap to fill the window — a clean,
|
||||
evenly-sized grid. Each card has a grip handle (left rail) as the drag
|
||||
initiator (the card body is full of buttons, so dragging the whole card
|
||||
was unreliable). Drop on another card to reorder. */}
|
||||
<div className="flex flex-wrap gap-4 items-start">
|
||||
{/* Dashboard of FIXED-WIDTH cards that wrap. "Auto" fills the window; a fixed
|
||||
column count caps the container width so cards wrap onto more lines. Each
|
||||
card has a grip handle (left rail) as the drag initiator (the card body is
|
||||
full of buttons, so dragging the whole card was unreliable). */}
|
||||
<div className="flex flex-wrap gap-4 items-start"
|
||||
style={cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : undefined}>
|
||||
{ordered.map((w) => (
|
||||
<div key={w.id} className="flex items-stretch w-[430px]"
|
||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||
|
||||
Reference in New Issue
Block a user