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:
2026-07-23 13:22:02 +02:00
parent 1070637c40
commit 2823f3e401
13 changed files with 606 additions and 214 deletions
+82
View File
@@ -5171,6 +5171,11 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
if a.qso == nil { if a.qso == nil {
return 0, fmt.Errorf("db not initialized") return 0, fmt.Errorf("db not initialized")
} }
// Frequency is numeric and drives the band, so it takes a dedicated path
// (freq_hz + band updated together) rather than the generic text setter.
if field == "freq" {
return a.bulkSetFrequency(ids, value)
}
col, ok := bulkFieldColumns[field] col, ok := bulkFieldColumns[field]
if !ok { if !ok {
return 0, fmt.Errorf("unknown field %q", field) return 0, fmt.Errorf("unknown field %q", field)
@@ -5189,6 +5194,34 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
return n, nil return n, nil
} }
// bulkSetFrequency parses a MHz frequency and sets freq_hz + band across the
// selected QSOs. Accepts a comma or dot decimal separator (fr locale) and
// rejects anything outside the ham bands so a typo can't corrupt the batch.
func (a *App) bulkSetFrequency(ids []int64, value string) (int64, error) {
s := strings.ReplaceAll(strings.TrimSpace(value), ",", ".")
if s == "" {
return 0, fmt.Errorf("enter a frequency in MHz (e.g. 7.155)")
}
mhz, err := strconv.ParseFloat(s, 64)
if err != nil || mhz <= 0 {
return 0, fmt.Errorf("invalid frequency %q — enter MHz, e.g. 7.155", value)
}
hz := int64(math.Round(mhz * 1_000_000))
band := cat.BandFromHz(hz)
if band == "" {
return 0, fmt.Errorf("%.4f MHz is not in a ham band", mhz)
}
n, err := a.qso.BulkSetFrequency(a.ctx, ids, hz, band)
if err != nil {
return 0, err
}
if n > 0 {
a.invalidateAwardStats()
a.materializeAwardRefsForIDs(ids) // band change may shift award slots → refresh
}
return n, nil
}
// WorkedBefore returns prior contacts with the given callsign at both // WorkedBefore returns prior contacts with the given callsign at both
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function // call and DXCC granularity. Pass dxccHint=0 when unknown — the function
// will infer it from past QSOs with the same call when possible. // will infer it from past QSOs with the same call when possible.
@@ -7178,6 +7211,55 @@ func (a *App) SendQSORecordingEmail(id int64) error {
return nil return nil
} }
// developerEmail is where "Help → Send log to F4BPO" delivers diagnostic logs.
const developerEmail = "[email protected]"
// SMTPConfigured reports whether an SMTP server is set up, so the UI can show the
// "Send log to F4BPO" help entry only when there's actually a way to send.
func (a *App) SMTPConfigured() bool {
s, _ := a.GetEmailSettings()
return strings.TrimSpace(s.Host) != ""
}
// SendLogToDeveloper e-mails the app's diagnostic logs (opslog.log, the previous
// session's opslog.log.1, and any crash log) to the developer so problems users
// hit in the field can be diagnosed. Requires a configured SMTP server
// (Settings → E-mail). A short header identifies the sender's call and version.
func (a *App) SendLogToDeveloper() error {
s, _ := a.GetEmailSettings()
if strings.TrimSpace(s.Host) == "" {
return fmt.Errorf("no SMTP server configured — set one in Settings → E-mail")
}
logPath := applog.Path()
if logPath == "" {
return fmt.Errorf("log file not available")
}
// The current log, plus the rotated one and the crash log when they exist.
attach := []string{logPath}
for _, extra := range []string{logPath + ".1", filepath.Join(filepath.Dir(logPath), "opslog-crash.log")} {
if _, err := os.Stat(extra); err == nil {
attach = append(attach, extra)
}
}
call := ""
if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil {
call = p.Callsign
}
}
subject := fmt.Sprintf("OpsLog log — v%s", appVersion)
if call != "" {
subject = fmt.Sprintf("OpsLog log — %s v%s", call, appVersion)
}
body := fmt.Sprintf("OpsLog diagnostic log attached.\n\nCallsign: %s\nVersion: %s\n\n73", call, appVersion)
if err := email.SendFiles(a.emailConfig(s), developerEmail, subject, body, attach); err != nil {
applog.Printf("email: send log to developer failed: %v", err)
return err
}
applog.Printf("email: diagnostic log sent to developer (%s)", developerEmail)
return nil
}
// ── ClubLog Country File (cty.xml) exceptions ───────────────────────── // ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
// ClublogCtyInfo is the UI status of the ClubLog exception data. // ClublogCtyInfo is the UI status of the ClubLog exception data.
+12 -4
View File
@@ -4,21 +4,29 @@
"date": "2026-07-22", "date": "2026-07-22",
"en": [ "en": [
"Clicking an OpsLog spot on the FlexRadio panadapter now switches the mode too, not just the frequency: cluster spots (which carry no mode) get one inferred from the comment or the band plan, and every spot's mode is sent to the radio as a real SmartSDR mode (SSB resolves to USB/LSB by frequency), so the click tunes AND sets the mode.", "Clicking an OpsLog spot on the FlexRadio panadapter now switches the mode too, not just the frequency: cluster spots (which carry no mode) get one inferred from the comment or the band plan, and every spot's mode is sent to the radio as a real SmartSDR mode (SSB resolves to USB/LSB by frequency), so the click tunes AND sets the mode.",
"Station Control is now a clean dashboard of fixed-width panels that wrap to fill the window. Each panel has a grip handle on its left edge — drag it to move the panel anywhere, and the order is remembered. Relay buttons are compact. The amplifier panel now shows the same live meters (forward power, drain current, PA temperature) as the FlexRadio panel.", "Station Control is now a clean dashboard of fixed-width panels that wrap to fill the window. Each panel has a grip handle on its left edge — drag it to move the panel anywhere, and the order is remembered. Relay buttons are compact. The amplifier panel is now the exact same card as the FlexRadio panel's (OPERATE/STANDBY, ON/OFF, power level, output-power bar and live FWD/drain-current/temperature meters), and if you run several amplifiers they all appear — one card each, no dropdown.",
"Station Control: the Ultrabeam element list now shows the right number of elements (3 for a 3-element beam) instead of extra bogus rows that came from over-reading the controller's reply.", "Station Control: the Ultrabeam element list now shows the right number of elements (3 for a 3-element beam) instead of extra bogus rows that came from over-reading the controller's reply.",
"Fixed the FlexRadio amplifier meters showing twice after the amp was power-cycled — the old (stale) meters are now cleared when the amplifier is torn down, so only the fresh set remains.", "Fixed the FlexRadio amplifier meters showing twice after the amp was power-cycled — the old (stale) meters are now cleared when the amplifier is torn down, so only the fresh set remains.",
"Fixed QSO audio recordings silently not being saved: the recording snapshot had moved onto the background (post-log) path, where it raced the entry-form clearing that cancels the recorder — so the audio was discarded before it was captured. The snapshot is now taken the instant you log, before the form clears; the file encoding still happens in the background.", "Fixed QSO audio recordings silently not being saved: the recording snapshot had moved onto the background (post-log) path, where it raced the entry-form clearing that cancels the recorder — so the audio was discarded before it was captured. The snapshot is now taken the instant you log, before the form clears; the file encoding still happens in the background.",
"Digital Voice Keyer: an Auto CQ (like the CW keyer's auto-call) repeats a CQ-labelled voice message on a timer until you stop it, play another slot or press ESC. The DVK now also refuses to transmit on non-phone modes (CW/FT8/RTTY…) — the buttons grey out and it won't key the rig with speech on a data mode.",
"The Digital Voice Keyer (DVK) panel now stays open across a relaunch if you had it open — it used to reset to closed every time.", "The Digital Voice Keyer (DVK) panel now stays open across a relaunch if you had it open — it used to reset to closed every time.",
"FlexRadio meter scales now match SmartSDR: MIC (level) tops out at 0 dB and COMP (compression) tops out at -25 dB (was -20)." "FlexRadio meter scales now match SmartSDR: MIC (level) tops out at 0 dB and COMP (compression) tops out at -25 dB (was -20).",
"New Help → 'Send log to F4BPO' (shown only when you have an SMTP server configured in Settings → E-mail): e-mails OpsLog's diagnostic logs (current session, previous session and any crash log) straight to the developer, so a problem you hit in the field can be looked at without hunting for the log files by hand.",
"Bulk edit can now set the frequency: pick 'Frequency (MHz)', enter e.g. 7.155, and every selected QSO gets that frequency with its band recomputed to match. Handy for fixing a run that was logged on a stale/default frequency (e.g. 7.000) after CAT control dropped. Out-of-band values are rejected so a typo can't corrupt the batch.",
"Fixed the Cluster spot list appearing to 'freeze' at the UTC midnight rollover: the Time column was sorted as plain text, so a spot at 0001Z sorted below 2359Z and new spots after 0000Z dropped to the bottom of the list instead of the top — it looked like the cluster had stopped. The column now sorts by actual arrival time, which crosses midnight correctly."
], ],
"fr": [ "fr": [
"Cliquer sur un spot OpsLog du panadapter FlexRadio change maintenant aussi le mode, plus seulement la fréquence : les spots cluster (qui n'ont pas de mode) en reçoivent un déduit du commentaire ou du plan de bande, et le mode de chaque spot est envoyé à la radio comme un vrai mode SmartSDR (SSB devient USB/LSB selon la fréquence), donc le clic accorde ET règle le mode.", "Cliquer sur un spot OpsLog du panadapter FlexRadio change maintenant aussi le mode, plus seulement la fréquence : les spots cluster (qui n'ont pas de mode) en reçoivent un déduit du commentaire ou du plan de bande, et le mode de chaque spot est envoyé à la radio comme un vrai mode SmartSDR (SSB devient USB/LSB selon la fréquence), donc le clic accorde ET règle le mode.",
"Station Control est maintenant un tableau de bord de panneaux à largeur fixe qui s'alignent pour remplir la fenêtre. Chaque panneau a une poignée sur son bord gauche — glisse-la pour déplacer le panneau où tu veux, et l'ordre est mémorisé. Les boutons relais sont compacts. Le panneau amplificateur affiche maintenant les mêmes mesures en direct (puissance directe, courant de drain, température PA) que le panneau FlexRadio.", "Station Control est maintenant un tableau de bord de panneaux à largeur fixe qui s'alignent pour remplir la fenêtre. Chaque panneau a une poignée sur son bord gauche — glisse-la pour déplacer le panneau où tu veux, et l'ordre est mémorisé. Les boutons relais sont compacts. Le panneau amplificateur est désormais exactement la même carte que celle du panneau FlexRadio (OPERATE/STANDBY, ON/OFF, niveau de puissance, barre de puissance de sortie et mesures en direct puissance directe/courant de drain/température), et si tu utilises plusieurs amplis ils apparaissent tous — une carte chacun, sans liste déroulante.",
"Station Control : la liste des éléments Ultrabeam affiche maintenant le bon nombre d'éléments (3 pour une beam 3 éléments) au lieu de lignes fantômes issues d'une sur-lecture de la réponse du contrôleur.", "Station Control : la liste des éléments Ultrabeam affiche maintenant le bon nombre d'éléments (3 pour une beam 3 éléments) au lieu de lignes fantômes issues d'une sur-lecture de la réponse du contrôleur.",
"Correction des mesures de l'amplificateur FlexRadio affichées en double après un cycle d'alimentation de l'ampli — les anciennes mesures (périmées) sont maintenant effacées quand l'amplificateur est retiré, seul le jeu à jour reste.", "Correction des mesures de l'amplificateur FlexRadio affichées en double après un cycle d'alimentation de l'ampli — les anciennes mesures (périmées) sont maintenant effacées quand l'amplificateur est retiré, seul le jeu à jour reste.",
"Correction des enregistrements audio des QSO qui n'étaient plus sauvegardés : la capture de l'enregistrement était passée sur le chemin d'arrière-plan (après le log), où elle entrait en concurrence avec le vidage du formulaire qui annule l'enregistreur — l'audio était donc jeté avant d'être capturé. La capture se fait maintenant à l'instant du log, avant le vidage du formulaire ; l'encodage du fichier reste en arrière-plan.", "Correction des enregistrements audio des QSO qui n'étaient plus sauvegardés : la capture de l'enregistrement était passée sur le chemin d'arrière-plan (après le log), où elle entrait en concurrence avec le vidage du formulaire qui annule l'enregistreur — l'audio était donc jeté avant d'être capturé. La capture se fait maintenant à l'instant du log, avant le vidage du formulaire ; l'encodage du fichier reste en arrière-plan.",
"Manipulateur vocal (DVK) : un Auto CQ (comme l'auto-call du keyer CW) répète un message vocal libellé CQ à intervalle régulier jusqu'à l'arrêt, la lecture d'un autre slot ou ÉCHAP. Le DVK refuse aussi désormais d'émettre hors phonie (CW/FT8/RTTY…) — les boutons sont grisés et il ne manipule plus la radio avec de la voix sur un mode data.",
"Le panneau Digital Voice Keyer (DVK) reste maintenant ouvert après un relancement si vous l'aviez ouvert — il se refermait à chaque démarrage.", "Le panneau Digital Voice Keyer (DVK) reste maintenant ouvert après un relancement si vous l'aviez ouvert — il se refermait à chaque démarrage.",
"Les échelles des meters FlexRadio collent maintenant à SmartSDR : MIC (niveau) plafonne à 0 dB et COMP (compression) à -25 dB (au lieu de -20)." "Les échelles des meters FlexRadio collent maintenant à SmartSDR : MIC (niveau) plafonne à 0 dB et COMP (compression) à -25 dB (au lieu de -20).",
"Nouveau Aide → « Envoyer le journal à F4BPO » (affiché seulement si un serveur SMTP est configuré dans Réglages → E-mail) : envoie par e-mail les journaux de diagnostic d'OpsLog (session en cours, session précédente et éventuel journal de crash) directement au développeur, pour qu'un problème rencontré sur le terrain puisse être examiné sans avoir à chercher les fichiers de journal à la main.",
"L'édition groupée permet maintenant de définir la fréquence : choisis « Fréquence (MHz) », saisis p. ex. 7.155, et chaque QSO sélectionné reçoit cette fréquence avec sa bande recalculée en conséquence. Pratique pour corriger une série loguée sur une fréquence par défaut/figée (p. ex. 7.000) après une perte du CAT. Les valeurs hors bande sont refusées pour qu'une faute de frappe ne corrompe pas le lot.",
"Correction de la liste des spots Cluster qui semblait « se figer » au passage de minuit UTC : la colonne Heure était triée comme du texte, donc un spot à 0001Z se retrouvait sous 2359Z et les nouveaux spots après 0000Z tombaient en bas de la liste au lieu du haut — on aurait dit que le cluster s'était arrêté. La colonne trie désormais selon l'heure d'arrivée réelle, qui franchit minuit correctement."
] ]
}, },
{ {
+93 -7
View File
@@ -14,6 +14,7 @@ import {
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings, LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog, GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
SMTPConfigured, SendLogToDeveloper,
WorkedBefore, WorkedBefore,
SetCompactMode, SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
@@ -959,8 +960,10 @@ export default function App() {
const dvkActiveRef = useRef(false); const dvkActiveRef = useRef(false);
const dvkPlayingRef = useRef(false); const dvkPlayingRef = useRef(false);
const dvkPlayRef = useRef<(slot: number) => void>(() => {}); const dvkPlayRef = useRef<(slot: number) => void>(() => {});
const dvkMsgsRef = useRef<DVKMsg[]>([]);
useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]); useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]);
useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]); useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]);
useEffect(() => { dvkMsgsRef.current = dvkMsgs; }, [dvkMsgs]);
useEffect(() => { useEffect(() => {
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat)); const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
return () => { off?.(); }; return () => { off?.(); };
@@ -972,7 +975,48 @@ export default function App() {
reloadDvk(); reloadDvk();
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {}); GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
}, [dvkEnabled, reloadDvk]); }, [dvkEnabled, reloadDvk]);
const dvkPlay = useCallback((slot: number) => { DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e))); }, []); // DVK auto-CQ: repeat a CQ voice message on a timer, like the CW keyer's
// auto-call. Only slots whose LABEL contains "CQ" loop; any other slot plays
// once (and stops a running loop), so a report doesn't keep repeating.
const [dvkAutoCq, setDvkAutoCq] = useState(() => localStorage.getItem('opslog.dvkAutoCq') === '1');
const [dvkAutoCqSecs, setDvkAutoCqSecs] = useState(() => Number(localStorage.getItem('opslog.dvkAutoCqSecs')) || 3);
const dvkAutoCqRef = useRef(dvkAutoCq);
const dvkAutoCqSecsRef = useRef(dvkAutoCqSecs);
const dvkAutoCqGenRef = useRef(0);
const dvkAutoCqSlotRef = useRef(-1);
useEffect(() => { dvkAutoCqRef.current = dvkAutoCq; localStorage.setItem('opslog.dvkAutoCq', dvkAutoCq ? '1' : '0'); }, [dvkAutoCq]);
useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]);
// The DVK is a VOICE keyer — transmitting it on CW/FT8/RTTY would key the rig
// with speech on a data slot. Only allow it on phone modes.
const isPhoneMode = (m: string) => RECORDABLE_MODES.has((m || '').toUpperCase());
const modeRef = useRef(mode);
useEffect(() => { modeRef.current = mode; }, [mode]);
function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
async function runDvkAutoCq(slot: number) {
const gen = ++dvkAutoCqGenRef.current;
dvkAutoCqSlotRef.current = slot;
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
while (dvkAutoCqSlotRef.current === slot && gen === dvkAutoCqGenRef.current && dvkActiveRef.current) {
if (!isPhoneMode(modeRef.current)) { stopDvkAutoCq(); break; }
await DVKPlay(slot).catch(() => {});
await sleep(300); // let playback flip "playing" true
let guard = 0; // then wait for it to finish (cap ~90 s)
while (dvkPlayingRef.current && gen === dvkAutoCqGenRef.current && guard < 600) { await sleep(150); guard++; }
if (gen !== dvkAutoCqGenRef.current) break;
await sleep(Math.max(0, dvkAutoCqSecsRef.current) * 1000); // gap before the next CQ
}
}
const dvkPlay = useCallback((slot: number) => {
if (!isPhoneMode(modeRef.current)) { setError(t('dvkp.notPhone')); return; }
const m = dvkMsgsRef.current.find((x) => x.slot === slot);
const isCQ = (m?.label || '').toUpperCase().includes('CQ');
if (dvkAutoCqRef.current && isCQ) {
runDvkAutoCq(slot); // loop this CQ until Stop / a non-CQ slot / mode change
} else {
stopDvkAutoCq();
DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e)));
}
}, []);
useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]); useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]);
// Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch // Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch
// it from the keyboard without clashing with the F1-F12 keyer macros). // it from the keyboard without clashing with the F1-F12 keyer macros).
@@ -1190,6 +1234,15 @@ export default function App() {
useEffect(() => { useEffect(() => {
GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {}); GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
}, []); }, []);
// Whether an SMTP server is configured — gates the "Send log to F4BPO" help
// entry. Re-checked when the Settings modal closes (the user may have just set
// it up), so the entry appears without a restart.
const [smtpConfigured, setSmtpConfigured] = useState(false);
const [sendingLog, setSendingLog] = useState(false);
useEffect(() => {
if (showSettings) return; // re-check after closing Settings
SMTPConfigured().then((v: any) => setSmtpConfigured(!!v)).catch(() => {});
}, [showSettings]);
const [showDuplicates, setShowDuplicates] = useState(false); const [showDuplicates, setShowDuplicates] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null); const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
const [checkingUpdate, setCheckingUpdate] = useState(false); const [checkingUpdate, setCheckingUpdate] = useState(false);
@@ -2869,9 +2922,15 @@ export default function App() {
]}, ]},
{ name: 'help', label: t('menu.help'), items: [ { name: 'help', label: t('menu.help'), items: [
{ type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' }, { type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' },
// Only when SMTP is set up: e-mail the diagnostic logs to the developer.
...(smtpConfigured ? [
{ type: 'separator' as const },
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
] : []),
{ type: 'separator' },
{ type: 'item', label: t('help.about'), action: 'help.about' }, { type: 'item', label: t('help.about'), action: 'help.about' },
]}, ]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, t]); ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
function handleMenu(action: string) { function handleMenu(action: string) {
switch (action) { switch (action) {
@@ -2900,6 +2959,21 @@ export default function App() {
case 'tools.downloadRefs': downloadRefs(); break; case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); checkUpdateNow(); break; case 'help.about': setShowAbout(true); checkUpdateNow(); break;
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break; case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
case 'help.sendlog': sendLogToDeveloper(); break;
}
}
// E-mail the diagnostic logs to the developer (Help → Send log to F4BPO).
async function sendLogToDeveloper() {
if (sendingLog) return;
setSendingLog(true);
try {
await SendLogToDeveloper();
showToast(t('help.sendLogOk'));
} catch (e: any) {
showToast(t('help.sendLogFail', { err: String(e?.message ?? e) }));
} finally {
setSendingLog(false);
} }
} }
@@ -2943,8 +3017,10 @@ export default function App() {
// callsign depends on the "ESC clears callsign" option; with the keyer // callsign depends on the "ESC clears callsign" option; with the keyer
// off it always resets the entry (the classic behaviour). // off it always resets the entry (the classic behaviour).
if (e.key === 'Escape') { if (e.key === 'Escape') {
// If a voice message is transmitting, ESC just stops it (keeps entry). // If a voice message is transmitting (or auto-CQ is looping), ESC stops it
if (dvkActiveRef.current && dvkPlayingRef.current) { // and cancels the loop (keeps entry).
if (dvkActiveRef.current && (dvkPlayingRef.current || dvkAutoCqSlotRef.current >= 0)) {
stopDvkAutoCq();
DVKStop(); DVKStop();
e.preventDefault(); e.preventDefault();
return; return;
@@ -4420,7 +4496,11 @@ export default function App() {
Digital Voice Keyer take this slot when enabled (Log4OM-style); Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */} otherwise it shows the QRZ profile photo. */}
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && ( {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch"> // relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
// the DVK with Auto CQ) can't grow the row — the row height stays set by
// the entry strip and each widget fills that height, scrolling inside.
<div className="flex-1 min-w-0 min-h-0 relative">
<div className="absolute inset-0 flex gap-2.5 items-stretch">
{/* Multi-op "who's on air" widget: every operator on the shared logbook, {/* Multi-op "who's on air" widget: every operator on the shared logbook,
their freq/mode (colour-coded) and OpsLog version. */} their freq/mode (colour-coded) and OpsLog version. */}
{showLiveStations && dbConn?.backend === 'mysql' && ( {showLiveStations && dbConn?.backend === 'mysql' && (
@@ -4503,13 +4583,18 @@ export default function App() {
</div> </div>
)} )}
{dvkEnabled && ( {dvkEnabled && (
<div className="w-[264px] shrink-0 min-h-0"> <div className="w-[320px] shrink-0 min-h-0">
<DvkPanel <DvkPanel
messages={dvkMsgs} messages={dvkMsgs}
status={dvkStat} status={dvkStat}
onPlay={dvkPlay} onPlay={dvkPlay}
onStop={() => DVKStop()} onStop={() => { stopDvkAutoCq(); DVKStop(); }}
onClose={() => setDvkEnabled(false)} onClose={() => setDvkEnabled(false)}
autoCq={dvkAutoCq}
autoCqSecs={dvkAutoCqSecs}
onToggleAutoCq={setDvkAutoCq}
onSetAutoCqSecs={(n) => setDvkAutoCqSecs(Math.max(0, Math.min(120, n || 0)))}
phoneOk={isPhoneMode(mode)}
/> />
</div> </div>
)} )}
@@ -4574,6 +4659,7 @@ export default function App() {
</div> </div>
)} )}
</div> </div>
</div>
)} )}
</div>{/* /entry + aside row */} </div>{/* /entry + aside row */}
+260
View File
@@ -0,0 +1,260 @@
import { useRef } from 'react';
import { Flame } from 'lucide-react';
import { cn } from '@/lib/utils';
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
// so Station Control (and anywhere else that needs it) shows the SAME card instead
// of a stripped-down variant. It handles all three amp families:
// • SPE Expert / ACOM — driven by OpsLog's own serial/TCP link (amp.spe/amp.acom)
// • PowerGenius XL — OPERATE + meters come from the Flex (which reports the
// amp), fan mode from the direct GSCP link (amp.pgxl)
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
// can each get their own card.
const METER_SEGMENTS = 26;
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', display, segColor, compact }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; display?: string;
segColor?: (frac: number) => string; compact?: boolean;
}) {
const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS);
return (
<div className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
compact ? 'px-2 py-1' : 'px-2.5 py-2')}>
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
{display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)}
</span>
</div>
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit;
const frac = i / METER_SEGMENTS;
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
return (
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
style={on
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
: { background: '#cfc6ad', opacity: 0.35 }} />
);
})}
</div>
</div>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
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">
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
</div>
);
}
// speMaxW / powerLevelLabel — same helpers the Flex panel uses.
function speMaxW(model?: string): number {
const m = (model || '').toUpperCase();
if (m.includes('20K') || m.includes('2K')) return 2000;
if (m.includes('15K')) return 1500;
return 1300;
}
function powerLevelLabel(pl?: string): string {
switch ((pl || '').trim().toUpperCase()) {
case 'L': return 'Low';
case 'M': return 'Mid';
case 'H': return 'High';
default: return pl || '';
}
}
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => {
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
const isSPE = !!amp.spe;
const isACOM = !!amp.acom;
if (isSPE) {
const spe = amp.spe;
return (
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!spe.connected}
onClick={() => AmpOperate(amp.id, !spe.operate).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{spe.operate ? 'OPERATE' : 'STANDBY'}
</button>
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!spe.connected}
onClick={() => AmpPower(amp.id, true).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!spe.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
</div>
<div className="inline-flex rounded-lg overflow-hidden border border-border">
{(['L', 'M', 'H'] as const).map((lvl, i) => {
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
return (
<button key={lvl} type="button" disabled={!spe.connected}
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{powerLevelLabel(lvl)}
</button>
);
})}
</div>
<span className={cn('inline-flex items-center gap-1.5 text-sm', spe.connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', spe.connected ? 'bg-success' : 'bg-danger')} />
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')}
</span>
{spe.connected && (
<span className="text-sm font-mono text-muted-foreground tabular-nums">
{spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)}
</span>
)}
<div className="flex-1" />
{(spe.warnings || spe.alarms) && (
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold"> {spe.warnings} {spe.alarms}</span>
)}
</div>
{spe.connected && (
<MeterBar label={t('flxp.outputPower')} value={Number(spe.output_w) || 0} unit="W"
lo={0} hi={speMaxW(spe.model)}
display={`${Number(spe.output_w) || 0} W`}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
)}
</Card>
);
}
if (isACOM) {
const acom = amp.acom;
return (
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!acom.connected}
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
acom.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{acom.operate ? 'OPERATE' : 'STANDBY'}
</button>
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
onClick={() => AmpPower(amp.id, true).catch(() => {})}
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!acom.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
</div>
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', acom.connected ? 'bg-success' : 'bg-danger')} />
{acom.connected ? acom.state : t('flxp.acomOffline')}
</span>
{acom.connected && (
<span className="text-sm font-mono text-muted-foreground tabular-nums">
{acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan}
</span>
)}
<div className="flex-1" />
{acom.err_text && (
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold"> {acom.err_text}</span>
)}
</div>
{acom.connected && (
<MeterBar label={t('flxp.outputPower')} value={Number(acom.fwd_w) || 0} unit="W"
lo={0} hi={Number(acom.max_w) || 800}
display={`${Number(acom.fwd_w) || 0} W`}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
)}
</Card>
);
}
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link.
const pg = amp.pgxl || {};
const viaFlex = !!flex?.amp_available;
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
const connected = !!pg.connected || viaFlex;
const fault = flex?.amp_fault;
return (
<Card icon={Flame} title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!connected}
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{operate ? 'OPERATE' : 'STANDBY'}
</button>
<span className="text-xs text-muted-foreground">
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
</span>
{(pg.host || pg.connected) && (
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-muted-foreground">{t('flxp.fan')}</span>
<select
disabled={!pg.connected}
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
className="h-8 rounded-md border border-warning-border bg-card px-2 text-xs font-semibold text-warning outline-none focus:border-warning disabled:opacity-40"
>
<option value="STANDARD">{t('flxp.fanStandard')}</option>
<option value="CONTEST">{t('flxp.fanContest')}</option>
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
</select>
</label>
)}
<div className="flex-1" />
{fault && fault !== 'NONE' && (
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
)}
</div>
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
{viaFlex && (() => {
const meters = (flex?.meters as any[]) || [];
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (amps.length === 0) return null;
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amps.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
}
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
let lo = m.lo, hi = m.hi;
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
lo = 0;
hi = m.hi >= 25 ? m.hi : 25;
}
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
})}
</div>
);
})()}
</Card>
);
}
+7 -3
View File
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
type FieldKind = 'status' | 'text'; type FieldKind = 'status' | 'text' | 'freq';
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean }; type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input. // Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
@@ -74,6 +74,9 @@ const FIELDS: FieldDef[] = [
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' }, { id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' }, { id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
// Misc // Misc
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
// logged on a stale/default frequency after CAT dropped.
{ id: 'freq', label: 'bulk.fFreq', group: 'Misc', kind: 'freq' },
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' }, { id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' }, { id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' }, { id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
@@ -180,7 +183,8 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
<Input <Input
className="h-8 text-xs" className="h-8 text-xs"
value={textValue} value={textValue}
placeholder={t('bulk.clearPlaceholder')} inputMode={def.kind === 'freq' ? 'decimal' : undefined}
placeholder={def.kind === 'freq' ? t('bulk.freqPlaceholder') : t('bulk.clearPlaceholder')}
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)} onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
/> />
)} )}
@@ -188,7 +192,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '} {t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })} <span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}{def.kind === 'freq' && effectiveValue !== '' ? ' MHz' : ''}</span> {t('bulk.onQsos', { n: ids.length })}
</div> </div>
{error && <div className="text-xs text-danger">{error}</div>} {error && <div className="text-xs text-danger">{error}</div>}
</div> </div>
+10
View File
@@ -149,6 +149,16 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono', headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
defaultVisible: true, defaultVisible: true,
sort: 'desc', sort: 'desc',
// Sort by the real arrival timestamp, NOT the "HHMMZ" display string. A
// lexical sort of time_utc breaks at the UTC day rollover: "0001Z" sorts
// below "2359Z", so spots received just after midnight fell to the bottom
// and looked like the cluster had stopped. received_at is a full datetime
// that keeps ordering correct across 0000Z.
comparator: (_a: any, _b: any, nodeA: any, nodeB: any) => {
const ta = Date.parse(nodeA?.data?.received_at ?? '') || 0;
const tb = Date.parse(nodeB?.data?.received_at ?? '') || 0;
return ta - tb;
},
cellStyle: { color: 'var(--muted-foreground)' }, cellStyle: { color: 'var(--muted-foreground)' },
}, },
{ {
+41 -10
View File
@@ -1,4 +1,4 @@
import { Mic, Square, X, Radio } from 'lucide-react'; import { Mic, Square, X, Radio, Repeat } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -12,14 +12,20 @@ type Props = {
onPlay: (slot: number) => void; onPlay: (slot: number) => void;
onStop: () => void; onStop: () => void;
onClose: () => void; onClose: () => void;
autoCq: boolean;
autoCqSecs: number;
onToggleAutoCq: (on: boolean) => void;
onSetAutoCqSecs: (n: number) => void;
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
}; };
// Operating panel for the Digital Voice Keyer — transmits the recorded F1F6 // Operating panel for the Digital Voice Keyer — transmits the recorded F1F6
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in // voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
// the reserved area. Recording/labeling lives in Settings → Audio. // the reserved area. Recording/labeling lives in Settings → Audio.
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) { export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const anyAudio = messages.some((m) => m.has_audio); const recorded = messages.filter((m) => m.has_audio);
const anyAudio = recorded.length > 0;
return ( return (
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden"> <div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0"> <div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
@@ -43,29 +49,54 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')} {t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-1"> // Only the recorded slots are shown. Two columns filling top-to-bottom
{messages.map((m) => ( // (rows sized to the count) — use the width instead of scrolling, so the
// panel keeps the same height as the other reserved-area widgets.
<div className="grid grid-flow-col gap-1"
style={{ gridTemplateRows: `repeat(${Math.max(1, Math.ceil(recorded.length / 2))}, minmax(0, auto))` }}>
{recorded.map((m) => (
<button <button
key={m.slot} key={m.slot}
type="button" type="button"
disabled={!m.has_audio} disabled={!phoneOk}
onClick={() => onPlay(m.slot)} onClick={() => onPlay(m.slot)}
title={m.has_audio ? t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) }) : t('dvkp.empty', { slot: m.slot })} title={!phoneOk ? t('dvkp.notPhone') : t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) })}
className={cn( className={cn(
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors', 'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
m.has_audio phoneOk
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer' ? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed', : 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
)} )}
> >
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span> <span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? t('dvkp.message') : '—')}</span> <span className="text-xs truncate flex-1">{m.label || t('dvkp.message')}</span>
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>} {(m.label || '').toUpperCase().includes('CQ') && autoCq && <Repeat className="size-3 text-primary shrink-0" />}
<span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
{/* Auto-CQ footer — repeats a CQ-labelled slot on a timer. Greyed out on a
non-phone mode (the DVK won't transmit there). */}
<div className="shrink-0 border-t border-border bg-muted/30 px-2 py-1.5 flex items-center gap-2">
<label className={cn('flex items-center gap-1.5 text-[11px] cursor-pointer', !phoneOk && 'opacity-50')} title={phoneOk ? t('dvkp.autoCqHint') : t('dvkp.notPhone')}>
<input type="checkbox" className="accent-primary" checked={autoCq} disabled={!phoneOk}
onChange={(e) => onToggleAutoCq(e.target.checked)} />
<Repeat className="size-3" /> {t('dvkp.autoCq')}
</label>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground">{t('dvkp.gap')}</span>
<input type="number" min={0} max={120} value={autoCqSecs}
onChange={(e) => onSetAutoCqSecs(parseInt(e.target.value, 10))}
className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono text-center" />
<span className="text-[10px] text-muted-foreground">s</span>
</div>
{!phoneOk && (
<div className="shrink-0 bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center">{t('dvkp.notPhone')}</div>
)}
</div> </div>
); );
} }
+42 -184
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -8,12 +8,13 @@ import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { AmpCard } from '@/components/AmpCard';
import { import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts, TestStationDevice, ListDenkoviDevices, ListSerialPorts, TestStationDevice,
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, GetFlexState, FlexAmpOperate, GetAmpStatuses, GetFlexState,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; 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) { export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
const { t } = useI18n(); const { t } = useI18n();
const [devices, setDevices] = useState<Device[]>([]); 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 []; } try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
}); });
const dragId = useRef<string | null>(null); 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 // Amplifiers (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. Re-read every 5s so enabling an // FlexRadio panel still get the controls. EVERY configured amp gets its own
// amp in Settings makes the widget appear without reopening the tab. // card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
const [ampCount, setAmpCount] = useState(0); // 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(() => { useEffect(() => {
let alive = true; 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(); load();
const id = window.setInterval(load, 5000); const id = window.setInterval(load, 1500);
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, []);
@@ -559,24 +404,36 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
if (ant.enabled) { if (ant.enabled) {
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> }); widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
} }
if (ampCount > 0) { // One card per configured amplifier (identical to the Flex panel's card).
widgets.push({ id: 'amplifier', node: <AmplifierWidget t={t} /> }); 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) }); 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 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 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 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 ( return (
<div className="flex-1 min-h-0 overflow-auto p-4"> <div className="flex-1 min-h-0 overflow-auto p-4">
<div className="flex items-center justify-between mb-3"> <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> <h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}> <div className="flex items-center gap-2">
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')} {/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
</Button> 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> </div>
{noDevices && !editing && ( {noDevices && !editing && (
@@ -585,11 +442,12 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div> </div>
)} )}
{/* Dashboard of FIXED-WIDTH cards that wrap to fill the window a clean, {/* Dashboard of FIXED-WIDTH cards that wrap. "Auto" fills the window; a fixed
evenly-sized grid. Each card has a grip handle (left rail) as the drag column count caps the container width so cards wrap onto more lines. Each
initiator (the card body is full of buttons, so dragging the whole card card has a grip handle (left rail) as the drag initiator (the card body is
was unreliable). Drop on another card to reorder. */} full of buttons, so dragging the whole card was unreliable). */}
<div className="flex flex-wrap gap-4 items-start"> <div className="flex flex-wrap gap-4 items-start"
style={cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : undefined}>
{ordered.map((w) => ( {ordered.map((w) => (
<div key={w.id} className="flex items-stretch w-[430px]" <div key={w.id} className="flex items-stretch w-[430px]"
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
File diff suppressed because one or more lines are too long
+4
View File
@@ -762,6 +762,8 @@ export function RotatorStop():Promise<void>;
export function RunBackupNow():Promise<string>; export function RunBackupNow():Promise<string>;
export function SMTPConfigured():Promise<boolean>;
export function SPESetOperate(arg1:boolean):Promise<void>; export function SPESetOperate(arg1:boolean):Promise<void>;
export function SPESetPower(arg1:boolean):Promise<void>; export function SPESetPower(arg1:boolean):Promise<void>;
@@ -842,6 +844,8 @@ export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<voi
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>; export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
export function SendLogToDeveloper():Promise<void>;
export function SendQSORecordingEmail(arg1:number):Promise<void>; export function SendQSORecordingEmail(arg1:number):Promise<void>;
export function SetAlertEmailTo(arg1:string):Promise<void>; export function SetAlertEmailTo(arg1:string):Promise<void>;
+8
View File
@@ -1478,6 +1478,10 @@ export function RunBackupNow() {
return window['go']['main']['App']['RunBackupNow'](); return window['go']['main']['App']['RunBackupNow']();
} }
export function SMTPConfigured() {
return window['go']['main']['App']['SMTPConfigured']();
}
export function SPESetOperate(arg1) { export function SPESetOperate(arg1) {
return window['go']['main']['App']['SPESetOperate'](arg1); return window['go']['main']['App']['SPESetOperate'](arg1);
} }
@@ -1638,6 +1642,10 @@ export function SendEQSL(arg1, arg2, arg3) {
return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3); return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3);
} }
export function SendLogToDeveloper() {
return window['go']['main']['App']['SendLogToDeveloper']();
}
export function SendQSORecordingEmail(arg1) { export function SendQSORecordingEmail(arg1) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1); return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
} }
+14 -2
View File
@@ -41,6 +41,16 @@ func (c Config) opts() []mail.Option {
// Send delivers a plain-text email to `to`, optionally attaching a file. // Send delivers a plain-text email to `to`, optionally attaching a file.
func Send(cfg Config, to, subject, body, attachPath string) error { func Send(cfg Config, to, subject, body, attachPath string) error {
var attach []string
if attachPath != "" {
attach = []string{attachPath}
}
return SendFiles(cfg, to, subject, body, attach)
}
// SendFiles is like Send but attaches any number of files (missing paths are
// skipped by the mail library at send time).
func SendFiles(cfg Config, to, subject, body string, attachPaths []string) error {
if cfg.Host == "" { if cfg.Host == "" {
return fmt.Errorf("SMTP server not configured") return fmt.Errorf("SMTP server not configured")
} }
@@ -65,8 +75,10 @@ func Send(cfg Config, to, subject, body, attachPath string) error {
} }
m.Subject(subject) m.Subject(subject)
m.SetBodyString(mail.TypeTextPlain, body) m.SetBodyString(mail.TypeTextPlain, body)
if attachPath != "" { for _, p := range attachPaths {
m.AttachFile(attachPath) if p != "" {
m.AttachFile(p)
}
} }
client, err := mail.NewClient(cfg.Host, cfg.opts()...) client, err := mail.NewClient(cfg.Host, cfg.opts()...)
if err != nil { if err != nil {
+25
View File
@@ -792,6 +792,31 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
return n, nil return n, nil
} }
// BulkSetFrequency sets freq_hz AND band together on every listed QSO. Kept
// separate from BulkSetField because frequency is numeric and must keep the band
// consistent — the main use is fixing a batch that was logged on a stale/default
// frequency after CAT dropped. freqHz "" is not allowed (caller validates).
func (r *Repo) BulkSetFrequency(ctx context.Context, ids []int64, freqHz int64, band string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+3)
args = append(args, freqHz, band, db.NowISO())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
args...)
if err != nil {
return 0, fmt.Errorf("bulk set frequency: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of // SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of
// the row. A slow caller that only wants to stamp its own app field (e-mailing a // the row. A slow caller that only wants to stamp its own app field (e-mailing a
// QSL card, saving a recording) must not do a full-row Update: the row it read may // QSL card, saving a recording) must not do a full-row Update: the row it read may