chore: release v0.26.2

This commit is contained in:
2026-08-21 18:21:33 +02:00
parent c49faf9a14
commit 0cd243fe00
28 changed files with 1600 additions and 345 deletions
+202 -2
View File
@@ -47,7 +47,8 @@ import {
TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus,
GetScpStatus, SetScpEnabled, DownloadScp,
DownloadULSCounties, ULSStatus, BackfillUSCounties,
DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
ComputeStationInfo,
GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
@@ -203,6 +204,7 @@ type SectionId =
| 'database'
| 'autostart'
| 'uscounties'
| 'databases'
| 'awards'
| 'cat'
| 'ftx'
@@ -320,6 +322,15 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
],
},
{
// Maintenance is about DATA rather than settings: nothing in here changes
// how OpsLog behaves, it refreshes what it knows. That is why the reference
// lists moved out of the Awards manager — an operator updating SOTA is not
// editing an award, and had to open one to do it.
kind: 'group', label: t('nav.maintenance'), icon: Database, defaultOpen: true, children: [
{ kind: 'item', label: t('sec.databases'), id: 'databases' },
],
},
{
kind: 'group', label: t('nav.hardware'), icon: Server, defaultOpen: true, children: hardware,
},
@@ -335,6 +346,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
foldersync: 'sec.foldersync',
webpublish: 'sec.webpublish',
uscounties: 'sec.uscounties',
databases: 'sec.databases',
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
antgenius: 'sec.antgenius', tunergenius: 'sec.tunergenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
relayauto: 'sec.relayauto',
@@ -628,6 +640,19 @@ function AutostartPanelComponent() {
<Input className="h-8 font-mono text-xs" value={p.args} placeholder="optional command-line arguments"
onChange={(e) => patch(p.id, { args: e.target.value })} />
</div>
{/* Only what OpsLog started is ever closed a copy the operator
opened themselves is theirs. The hint says so, because the
difference is invisible from here. */}
<label className="flex items-start gap-2 text-xs cursor-pointer">
<Checkbox className="mt-0.5" checked={!!(p as any).close_on_exit}
onCheckedChange={(c) => patch(p.id, { close_on_exit: !!c } as any)} />
<span>
Close it when OpsLog closes{' '}
<span className="text-muted-foreground">
(asks it to close, the way clicking its cross would and only if OpsLog started it)
</span>
</span>
</label>
{launchMsg[p.id] && <div className="text-xs text-muted-foreground pl-[86px]">{launchMsg[p.id]}</div>}
</div>
))}
@@ -1720,6 +1745,45 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
try { await DownloadULSCounties(); }
catch (e: any) { setUlsBusy(false); setUlsProgress(null); setUlsMsg({ ok: false, text: String(e?.message ?? e) }); }
};
// cty.dat is not downloaded by OpsLog — it is shipped and reloaded from disk —
// so the page reports it rather than offering a button it cannot honour.
const [ctyInfo, setCtyInfo] = useState<{ entities: number; file_mod_time?: string }>({ entities: 0 });
const [ctyBusy, setCtyBusy] = useState(false);
useEffect(() => { GetCtyDatInfo().then((i) => setCtyInfo(i as any)).catch(() => {}); }, []);
const refreshCty = async () => {
setCtyBusy(true);
try { const i: any = await RefreshCtyDat(); setCtyInfo(i); } catch { /* the line keeps its old count */ }
finally { setCtyBusy(false); }
};
// Award reference lists, and which of them have an online updater.
const [refMeta, setRefMeta] = useState<Array<{ code: string; count: number; updated_at?: string; can_update: boolean }>>([]);
const [refBusy, setRefBusy] = useState('');
const [refMsg, setRefMsg] = useState('');
const reloadRefMeta = () => { GetAwardReferenceMeta().then((m) => setRefMeta((m ?? []) as any)).catch(() => {}); };
useEffect(reloadRefMeta, []);
const updateRefList = async (code: string) => {
setRefBusy(code); setRefMsg('');
try { const m: any = await UpdateAwardReferenceList(code); setRefMsg(t('db.refUpdated', { code, n: m?.count ?? 0 })); reloadRefMeta(); }
catch (e: any) { setRefMsg(String(e?.message ?? e)); }
finally { setRefBusy(''); }
};
// A date for a person: the day, not the timestamp the backend stores.
const fmtDay = (v?: string) => (v ? String(v).slice(0, 10) : '—');
const [rdaCount, setRdaCount] = useState(0);
const [rdaBusy, setRdaBusy] = useState(false);
const [rdaUseCurrent, setRdaUseCurrent] = useState(true);
const [rdaMsg, setRdaMsg] = useState<string | null>(null);
useEffect(() => { RDADatabaseCount().then((n) => setRdaCount(Number(n) || 0)).catch(() => {}); }, []);
const runRDABackfill = async () => {
setRdaBusy(true); setRdaMsg(null);
try {
const r: any = await BackfillRDA(rdaUseCurrent);
setRdaMsg(t('rda.backfillDone', {
d: r?.dated ?? 0, c: r?.current ?? 0, u: r?.unknown ?? 0, k: r?.skipped ?? 0, s: r?.scanned ?? 0,
}));
} catch (e: any) { setRdaMsg(String(e?.message ?? e)); }
finally { setRdaBusy(false); }
};
const runBackfill = async () => {
setBackfillBusy(true); setBackfillMsg(null);
try { const r: any = await BackfillUSCounties(); setBackfillMsg(t('uscty.backfillDone', { c: r?.county ?? 0, g: r?.grid ?? 0, s: r?.scanned ?? 0 })); }
@@ -6914,6 +6978,141 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
);
}
// One line per database: what it holds, when it was refreshed, and the button
// that refreshes it. Every one of these already had an updater somewhere —
// buried in the Awards manager, in a corner of the Lookup page, behind the
// US Counties section — and an operator wanting them all current had to know
// where each one lived. The point of this page is that they are in one place,
// named, with their own line.
function DatabasesPanel() {
const rows: Array<{
key: string; name: string; detail: string; busy: boolean; run?: () => void; note?: string;
}> = [
{
key: 'cty', name: t('db.cty'),
detail: ctyInfo?.entities ? t('db.ctyDetail', { n: ctyInfo.entities, d: fmtDay(ctyInfo.file_mod_time) }) : t('db.never'),
busy: ctyBusy, run: refreshCty,
},
{
key: 'clublog', name: t('db.clublog'),
detail: clubInfo?.count ? t('db.clublogDetail', { n: clubInfo.count, d: clubInfo.date || '—' }) : t('db.never'),
busy: clubBusy,
run: () => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); },
},
{
key: 'lotw', name: t('db.lotwUsers'),
detail: lotwUsers?.count ? t('db.lotwDetail', { n: lotwUsers.count, d: fmtDay(lotwUsers.updated) }) : t('db.never'),
busy: lotwUsersBusy, run: downloadLotwUsers,
},
{
key: 'scp', name: t('db.scp'),
detail: scp?.count ? t('db.scpDetail', { n: scp.count, d: fmtDay(scp.updated) }) : t('db.never'),
busy: scpBusy, run: downloadScp,
},
{
key: 'uls', name: t('db.uls'),
detail: ulsStatus?.count ? t('db.ulsDetail', { n: ulsStatus.count, d: fmtDay(ulsStatus.updated_at) }) : t('db.never'),
busy: ulsBusy, run: downloadUls,
},
{
key: 'rda', name: t('db.rda'),
detail: t('db.rdaDetail', { n: rdaCount.toLocaleString() }),
busy: false, note: t('db.rdaNote'),
},
];
return (
<div className="space-y-4 max-w-3xl">
<SectionHeader title={t('sec.databases')} hint={t('db.hint')} />
<div className="rounded-md border border-border divide-y divide-border">
{rows.map((r) => (
<div key={r.key} className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{r.name}</div>
<div className="text-[11px] text-muted-foreground">{r.detail}</div>
</div>
{r.run ? (
<Button size="sm" variant="secondary" onClick={r.run} disabled={r.busy}>
{r.busy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
{t('db.update')}
</Button>
) : (
<span className="text-[11px] text-muted-foreground italic shrink-0">{r.note}</span>
)}
</div>
))}
</div>
{/* The award reference lists. Their own block because they are one kind
of thing with one updater each, and because the list depends on which
awards are defined a shared WAPC brought in by an operator appears
here the day it can be updated. */}
<div className="space-y-2">
<div className="text-xs font-medium">{t('db.refLists')}</div>
<p className="text-[11px] text-muted-foreground">{t('db.refListsHint')}</p>
<div className="rounded-md border border-border divide-y divide-border">
{refMeta.filter((m) => m.can_update).map((m) => (
<div key={m.code} className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium font-mono">{m.code}</div>
<div className="text-[11px] text-muted-foreground">
{m.count ? t('db.refDetail', { n: m.count, d: fmtDay(m.updated_at) }) : t('db.never')}
</div>
</div>
<Button size="sm" variant="secondary" disabled={refBusy === m.code}
onClick={() => updateRefList(m.code)}>
{refBusy === m.code ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
{t('db.update')}
</Button>
</div>
))}
{refMeta.filter((m) => m.can_update).length === 0 && (
<div className="p-3 text-[11px] text-muted-foreground">{t('db.noRefLists')}</div>
)}
</div>
{refMsg && <p className="text-[11px] text-muted-foreground">{refMsg}</p>}
</div>
</div>
);
}
// Russian districts: the offline RDA database and the backfill it feeds.
//
// Its own section rather than a corner of the Awards manager: it is a
// database and a bulk operation on the log, which is what every other item in
// this part of the sidebar is.
function RDAPanel() {
return (
<div className="space-y-4 max-w-2xl">
<SectionHeader title={t('sec.rda')} hint={t('rda.hint')} />
<div className="rounded-md border border-border p-3 space-y-1">
<div className="text-xs font-medium">{t('rda.dbTitle')}</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('rda.dbCount', { n: rdaCount.toLocaleString() })}
</p>
</div>
<div className="rounded-md border border-border p-3 space-y-2">
<div className="text-xs font-medium">{t('rda.backfillTitle')}</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('rda.backfillIntro')}</p>
{/* The choice is the whole point of the panel, so it is a checkbox and
not a hidden default: a dated record is a fact, a current district
on a ten-year-old QSO is an assumption. */}
<label className="flex items-start gap-2 text-xs cursor-pointer">
<Checkbox checked={rdaUseCurrent} className="mt-0.5" onCheckedChange={(c) => setRdaUseCurrent(!!c)} />
<span>{t('rda.useCurrent')} <span className="text-muted-foreground">{t('rda.useCurrentHint')}</span></span>
</label>
<div className="flex items-center gap-3">
<Button size="sm" variant="secondary" onClick={runRDABackfill} disabled={rdaBusy}>
{rdaBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
{t('rda.backfillRun')}
</Button>
{rdaMsg && <span className="text-xs text-muted-foreground">{rdaMsg}</span>}
</div>
<p className="text-[11px] text-muted-foreground">{t('rda.neverOverwrites')}</p>
</div>
</div>
);
}
// Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = {
general: GeneralPanel,
@@ -6940,8 +7139,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
backup: BackupPanel,
database: DatabasePanel,
uscounties: USCountiesPanel,
databases: DatabasesPanel,
autostart: () => <AutostartPanelComponent />,
awards: () => <AwardsSelectionPanel profile={activeProfile ?? undefined} />,
awards: () => (<div className="space-y-6"><AwardsSelectionPanel profile={activeProfile ?? undefined} /><RDAPanel /></div>),
cat: CATPanel,
rotator: RotatorPanel,
winkeyer: WinkeyerPanel,