feat: never drop cluster spots + theme-aware spot badges + FR settings
Backend: replace the bounded cluster-event channel (which dropped spots under RBN bursts once enrichment/UI fell behind) with an unbounded clusterQueue (slice + sync.Cond). The socket-read goroutine still never blocks, but a burst now grows the backlog and drains instead of losing spots. Head compaction keeps a sustained burst from growing the backing array without bound. ClusterGrid: drop all hard-coded light-theme hex colours (Time, Country, Spotter, Comment, POTA, band/mode fills) for semantic CSS-var tokens so they read correctly on every theme. Replace the heavy full-cell band/mode fills with small rounded pills (cellChip). Only NEW MODE pills the mode cell — NEW SLOT (band and mode each worked, just not together) no longer highlights the mode cell, which wrongly implied the mode was new. App: stage incoming spots ~50ms and resolve their slot status before inserting, so a row paints with its badge already on instead of flashing plain text then flipping to a pill. Self-spot toast still fires per spot. i18n: translate the General settings that were still English — telemetry and live-status toggles, the Main view pane pickers and their options. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -435,8 +435,8 @@ type App struct {
|
|||||||
// to run inline in the read loop, so a single slow step stopped draining the
|
// to run inline in the read loop, so a single slow step stopped draining the
|
||||||
// TCP socket and the whole feed fell behind the node (visible as the grid
|
// TCP socket and the whole feed fell behind the node (visible as the grid
|
||||||
// lagging telnet). The read loop now just enqueues here; one worker does the work.
|
// lagging telnet). The read loop now just enqueues here; one worker does the work.
|
||||||
clusterEventCh chan clusterEvent
|
// Unbounded FIFO: a burst grows the backlog instead of dropping spots.
|
||||||
clusterDropped int64 // spots/lines dropped when the queue was full (atomic)
|
clusterEvents *clusterQueue
|
||||||
// wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never
|
// wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never
|
||||||
// queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL).
|
// queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL).
|
||||||
// Loaded once, appended to on each log, rebuilt after bulk changes.
|
// Loaded once, appended to on each log, rebuilt after bulk changes.
|
||||||
@@ -906,10 +906,11 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// renders the row with all metadata already filled (no flicker of
|
// renders the row with all metadata already filled (no flicker of
|
||||||
// empty Country / Cont columns while the batch status fetch runs).
|
// empty Country / Cont columns while the batch status fetch runs).
|
||||||
// Cluster events are processed OFF the socket-read goroutine (see clusterEvent /
|
// Cluster events are processed OFF the socket-read goroutine (see clusterEvent /
|
||||||
// clusterEventWorker). Sized large so ordinary traffic and even an SH/DX/100
|
// clusterEventWorker). The queue is UNBOUNDED so no spot is ever dropped: an
|
||||||
// burst never fill it; a full queue drops-and-counts rather than block the read
|
// SH/DX or RBN burst grows the backlog and drains once enrichment catches up,
|
||||||
// loop, which was the actual cause of the feed falling behind telnet.
|
// while the read loop still never blocks (that was the cause of the feed falling
|
||||||
a.clusterEventCh = make(chan clusterEvent, 8192)
|
// behind telnet).
|
||||||
|
a.clusterEvents = newClusterQueue()
|
||||||
go a.clusterEventWorker()
|
go a.clusterEventWorker()
|
||||||
|
|
||||||
a.cluster = cluster.NewManager(
|
a.cluster = cluster.NewManager(
|
||||||
@@ -5902,20 +5903,85 @@ type clusterEvent struct {
|
|||||||
line *cluster.Line
|
line *cluster.Line
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueueClusterEvent hands an event to the worker WITHOUT blocking. It runs on
|
// clusterQueue is an unbounded FIFO between the socket-read goroutines (producers,
|
||||||
// the cluster session's socket-read goroutine: blocking here would stop draining
|
// which must never block or the TCP feed stalls) and the single clusterEventWorker
|
||||||
// the TCP socket, the node's send buffer would fill, and the feed would fall
|
// (consumer). Unlike a bounded channel it NEVER drops an event: a burst just grows
|
||||||
// behind — the exact bug this indirection fixes. If the queue is full (processing
|
// the backlog, which drains once enrichment catches up.
|
||||||
// can't keep up), drop and count rather than stall the whole feed.
|
type clusterQueue struct {
|
||||||
func (a *App) enqueueClusterEvent(ev clusterEvent) {
|
mu sync.Mutex
|
||||||
select {
|
cond *sync.Cond
|
||||||
case a.clusterEventCh <- ev:
|
buf []clusterEvent
|
||||||
default:
|
head int // index of the next event to pop (waste reclaimed by compaction)
|
||||||
n := atomic.AddInt64(&a.clusterDropped, 1)
|
closed bool
|
||||||
if n == 1 || n%100 == 0 {
|
}
|
||||||
applog.Printf("cluster: processing backlog — dropped %d event(s); the feed is faster than enrichment/UI", n)
|
|
||||||
}
|
func newClusterQueue() *clusterQueue {
|
||||||
|
q := &clusterQueue{}
|
||||||
|
q.cond = sync.NewCond(&q.mu)
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// push appends an event and wakes the worker. Amortised O(1); never blocks, never
|
||||||
|
// drops.
|
||||||
|
func (q *clusterQueue) push(ev clusterEvent) {
|
||||||
|
q.mu.Lock()
|
||||||
|
if q.closed {
|
||||||
|
q.mu.Unlock()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
q.buf = append(q.buf, ev)
|
||||||
|
q.mu.Unlock()
|
||||||
|
q.cond.Signal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pop blocks until an event is available, returning ok=false only once the queue
|
||||||
|
// is closed AND fully drained.
|
||||||
|
func (q *clusterQueue) pop() (clusterEvent, bool) {
|
||||||
|
q.mu.Lock()
|
||||||
|
for q.head == len(q.buf) && !q.closed {
|
||||||
|
q.cond.Wait()
|
||||||
|
}
|
||||||
|
if q.head == len(q.buf) { // closed and drained
|
||||||
|
q.mu.Unlock()
|
||||||
|
return clusterEvent{}, false
|
||||||
|
}
|
||||||
|
ev := q.buf[q.head]
|
||||||
|
q.buf[q.head] = clusterEvent{} // release pointers held by the consumed slot
|
||||||
|
q.head++
|
||||||
|
switch {
|
||||||
|
case q.head == len(q.buf):
|
||||||
|
// Fully drained → reset to reuse the backing array from the front.
|
||||||
|
q.buf = q.buf[:0]
|
||||||
|
q.head = 0
|
||||||
|
case q.head > 1024 && q.head*2 >= len(q.buf):
|
||||||
|
// Head waste is large → compact so a long sustained burst doesn't grow
|
||||||
|
// the backing array without bound.
|
||||||
|
n := copy(q.buf, q.buf[q.head:])
|
||||||
|
for i := n; i < len(q.buf); i++ {
|
||||||
|
q.buf[i] = clusterEvent{}
|
||||||
|
}
|
||||||
|
q.buf = q.buf[:n]
|
||||||
|
q.head = 0
|
||||||
|
}
|
||||||
|
q.mu.Unlock()
|
||||||
|
return ev, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// close wakes any waiting consumer so it can exit once the backlog is drained.
|
||||||
|
func (q *clusterQueue) close() {
|
||||||
|
q.mu.Lock()
|
||||||
|
q.closed = true
|
||||||
|
q.mu.Unlock()
|
||||||
|
q.cond.Broadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueClusterEvent hands an event to the worker WITHOUT blocking and WITHOUT
|
||||||
|
// dropping. It runs on the cluster session's socket-read goroutine: blocking here
|
||||||
|
// would stop draining the TCP socket, the node's send buffer would fill, and the
|
||||||
|
// feed would fall behind — the exact bug this indirection fixes. The queue is
|
||||||
|
// unbounded, so a burst grows the backlog rather than losing a spot.
|
||||||
|
func (a *App) enqueueClusterEvent(ev clusterEvent) {
|
||||||
|
a.clusterEvents.push(ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clusterEventWorker drains clusterEventCh and does everything that used to run
|
// clusterEventWorker drains clusterEventCh and does everything that used to run
|
||||||
@@ -5923,7 +5989,11 @@ func (a *App) enqueueClusterEvent(ev clusterEvent) {
|
|||||||
// to the UI, run alert rules (which may query a remote MySQL) and mirror it to the
|
// to the UI, run alert rules (which may query a remote MySQL) and mirror it to the
|
||||||
// Flex panadapter — all serialised on this one goroutine, off the read path.
|
// Flex panadapter — all serialised on this one goroutine, off the read path.
|
||||||
func (a *App) clusterEventWorker() {
|
func (a *App) clusterEventWorker() {
|
||||||
for ev := range a.clusterEventCh {
|
for {
|
||||||
|
ev, ok := a.clusterEvents.pop()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
if ev.line != nil {
|
if ev.line != nil {
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "cluster:line", *ev.line)
|
wruntime.EventsEmit(a.ctx, "cluster:line", *ev.line)
|
||||||
|
|||||||
+78
-3
@@ -1064,6 +1064,15 @@ export default function App() {
|
|||||||
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
||||||
// different slots don't share the same colour.
|
// different slots don't share the same colour.
|
||||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
|
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
|
||||||
|
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
||||||
|
// still need resolving without re-subscribing the cluster:spot listener.
|
||||||
|
const spotStatusRef = useRef(spotStatus);
|
||||||
|
useEffect(() => { spotStatusRef.current = spotStatus; }, [spotStatus]);
|
||||||
|
// Incoming spots are staged here for a few ms, their status resolved, then
|
||||||
|
// committed to `spots` together — so a row paints with its NEW BAND/MODE badge
|
||||||
|
// already on, instead of flashing plain text then flipping to the pill.
|
||||||
|
const pendingSpotsRef = useRef<ClusterSpot[]>([]);
|
||||||
|
const pendingSpotTimer = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
// === Modals ===
|
// === Modals ===
|
||||||
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
||||||
@@ -1740,13 +1749,76 @@ export default function App() {
|
|||||||
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
|
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
|
||||||
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
|
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
|
||||||
});
|
});
|
||||||
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
// Commit the staged spots: resolve the status for any slot we don't know yet
|
||||||
|
// FIRST, then insert the rows — so they appear with the right badge already
|
||||||
|
// painted instead of flashing plain text then flipping to a pill.
|
||||||
|
const flushPendingSpots = async () => {
|
||||||
|
pendingSpotTimer.current = undefined;
|
||||||
|
const batch = pendingSpotsRef.current;
|
||||||
|
pendingSpotsRef.current = [];
|
||||||
|
if (batch.length === 0) return;
|
||||||
|
// Resolve unknown statuses before the rows go in.
|
||||||
|
try {
|
||||||
|
const known = spotStatusRef.current;
|
||||||
|
const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const s of batch) {
|
||||||
|
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||||
|
if (seen.has(k) || known[k]) continue;
|
||||||
|
seen.add(k);
|
||||||
|
unknown.push({
|
||||||
|
call: s.dx_call, band: s.band ?? '',
|
||||||
|
mode: inferSpotMode(s.comment ?? '', s.freq_hz),
|
||||||
|
pota_ref: (s as any).pota_ref ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
const res = await ClusterSpotStatuses(unknown as any);
|
||||||
|
setSpotStatus((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const r of res) {
|
||||||
|
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
||||||
|
next[k] = {
|
||||||
|
status: r.status ?? '',
|
||||||
|
country: r.country,
|
||||||
|
continent: (r as any).continent,
|
||||||
|
worked_call: !!(r as any).worked_call,
|
||||||
|
new_county: !!(r as any).new_county,
|
||||||
|
new_pota: !!(r as any).new_pota,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Now insert the staged rows. Same de-dupe as before: a station re-spotted
|
||||||
|
// (RBN skimmers on a CQ, several nodes relaying) shows as ONE live row that
|
||||||
|
// the freshest spot REPLACES and floats to the top. Historical (SH/DX
|
||||||
|
// replay) rows are left alone.
|
||||||
setSpots((arr) => {
|
setSpots((arr) => {
|
||||||
const next = [sp, ...arr];
|
const key = (x: ClusterSpot) => `${(x.dx_call ?? '').toUpperCase()}|${(x.band ?? '').toLowerCase()}`;
|
||||||
|
const hist = (x: ClusterSpot) => !!(x as any).historical;
|
||||||
|
let next = arr;
|
||||||
|
for (const sp of batch) {
|
||||||
|
const k = key(sp);
|
||||||
|
const filtered = hist(sp) ? next : next.filter((x) => hist(x) || key(x) !== k);
|
||||||
|
next = [sp, ...filtered];
|
||||||
|
}
|
||||||
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
|
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
||||||
|
// Stage the spot; a short timer resolves its status then commits it.
|
||||||
|
pendingSpotsRef.current.push(sp);
|
||||||
|
// 50 ms is enough to coalesce an RBN burst into one status lookup (the
|
||||||
|
// worked-index is in memory, so resolving is near-instant) while staying
|
||||||
|
// imperceptible.
|
||||||
|
if (pendingSpotTimer.current === undefined) {
|
||||||
|
pendingSpotTimer.current = window.setTimeout(flushPendingSpots, 50);
|
||||||
|
}
|
||||||
// Self-spot: someone spotted OUR callsign — show it in the shared header
|
// Self-spot: someone spotted OUR callsign — show it in the shared header
|
||||||
// toast (same place as the other notifications), not a separate banner.
|
// toast (same place as the other notifications), not a separate banner.
|
||||||
|
// Fired immediately (not staged) so the notification never lags the spot.
|
||||||
const mine = myCallRef.current;
|
const mine = myCallRef.current;
|
||||||
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
|
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
|
||||||
const by = cleanSpotter(sp.spotter ?? '') || '?';
|
const by = cleanSpotter(sp.spotter ?? '') || '?';
|
||||||
@@ -1754,7 +1826,10 @@ export default function App() {
|
|||||||
showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`);
|
showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { unsubState?.(); unsubSpot?.(); };
|
return () => {
|
||||||
|
unsubState?.(); unsubSpot?.();
|
||||||
|
if (pendingSpotTimer.current !== undefined) window.clearTimeout(pendingSpotTimer.current);
|
||||||
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,23 @@ function tok(name: string, text: string): Badge {
|
|||||||
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
|
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
|
||||||
|
// Status badges) when the slot is notable, instead of flooding the whole cell with
|
||||||
|
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
|
||||||
|
// null → plain text, no pill.
|
||||||
|
function cellChip(value: any, name: string | null): any {
|
||||||
|
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
||||||
|
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
|
||||||
|
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
|
||||||
|
border: `1px solid var(--${name}-border)`, fontWeight: 700, fontSize: 9,
|
||||||
|
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||||
|
}}>{txt}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
||||||
switch (s?.status) {
|
switch (s?.status) {
|
||||||
case 'new': return tok('danger', t('clg2.newDxcc'));
|
case 'new': return tok('danger', t('clg2.newDxcc'));
|
||||||
@@ -129,7 +146,7 @@ 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',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||||
@@ -204,7 +221,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#166534' },
|
cellStyle: { color: 'var(--success)' },
|
||||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -219,10 +236,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
// NEW BAND for this entity → small warning pill around the band text.
|
||||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
|
||||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
|
||||||
: undefined),
|
|
||||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -231,16 +246,11 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||||
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
|
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
|
||||||
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
||||||
cellStyle: (p: any) => {
|
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
||||||
const st = statusFor(p)?.status;
|
// that case is signalled by the Status badge alone.
|
||||||
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
|
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
|
||||||
// Status badge text tells them apart.
|
|
||||||
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const st = statusFor(p)?.status;
|
const st = statusFor(p)?.status;
|
||||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||||
@@ -252,7 +262,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||||
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
||||||
@@ -289,7 +299,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
||||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||||
]?.country ?? '',
|
]?.country ?? '',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
||||||
@@ -298,31 +308,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
||||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||||
]?.continent ?? '',
|
]?.continent ?? '',
|
||||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
||||||
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
||||||
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#9a8870', fontSize: 10 },
|
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
||||||
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
||||||
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
||||||
|
|||||||
@@ -540,6 +540,7 @@ function AutostartPanelComponent() {
|
|||||||
// (a random install ID + version + OS, sent once a day). Real component so it
|
// (a random install ID + version + OS, sent once a day). Real component so it
|
||||||
// can own its state; embedded inside GeneralPanel.
|
// can own its state; embedded inside GeneralPanel.
|
||||||
function TelemetryToggle() {
|
function TelemetryToggle() {
|
||||||
|
const { t } = useI18n();
|
||||||
const [on, setOn] = useState(true);
|
const [on, setOn] = useState(true);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -549,8 +550,8 @@ function TelemetryToggle() {
|
|||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={on} disabled={!loaded}
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
||||||
Send anonymous usage statistics
|
{t('settings.telemetry')}
|
||||||
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day — no callsign or QSO data)</span>
|
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -560,6 +561,7 @@ function TelemetryToggle() {
|
|||||||
// events — a small web script on your server renders it for the QRZ page. Only
|
// events — a small web script on your server renders it for the QRZ page. Only
|
||||||
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
||||||
function LiveStatusToggle() {
|
function LiveStatusToggle() {
|
||||||
|
const { t } = useI18n();
|
||||||
const [on, setOn] = useState(false);
|
const [on, setOn] = useState(false);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -569,7 +571,7 @@ function LiveStatusToggle() {
|
|||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={on} disabled={!loaded}
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
||||||
Publish live operator status <span className="text-xs text-muted-foreground">(multi-op on shared MySQL — feeds a QRZ live page)</span>
|
{t('settings.liveStatus')} <span className="text-xs text-muted-foreground">({t('settings.liveStatusHint')})</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -578,25 +580,20 @@ function LiveStatusToggle() {
|
|||||||
// panes show, independently: the great-circle map, the locator street map, the
|
// panes show, independently: the great-circle map, the locator street map, the
|
||||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||||
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||||
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
|
||||||
{ value: 'map1', label: 'Map — great-circle + beam' },
|
|
||||||
{ value: 'map2', label: 'Map — locator (street)' },
|
|
||||||
{ value: 'cluster', label: 'Cluster spots' },
|
|
||||||
{ value: 'worked', label: 'Worked before' },
|
|
||||||
{ value: 'recent', label: 'Recent QSOs' },
|
|
||||||
{ value: 'netcontrol', label: 'Net control' },
|
|
||||||
];
|
|
||||||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
|
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [left, setLeft] = useState('map1');
|
const [left, setLeft] = useState('map1');
|
||||||
const [right, setRight] = useState('map2');
|
const [right, setRight] = useState('map2');
|
||||||
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
|
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
|
||||||
const options = [
|
const options = [
|
||||||
...MAIN_PANE_OPTIONS,
|
...MAIN_PANE_VALUES,
|
||||||
...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []),
|
...(flexAvailable ? ['flex'] : []),
|
||||||
...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []),
|
...(icomAvailable ? ['icom'] : []),
|
||||||
].sort((a, b) => a.label.localeCompare(b.label));
|
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v);
|
||||||
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||||
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -609,11 +606,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
|
<h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
|
||||||
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
|
<p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
|
||||||
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
||||||
<label className="flex flex-col gap-1 text-xs">
|
<label className="flex flex-col gap-1 text-xs">
|
||||||
<span className="text-muted-foreground">Left pane</span>
|
<span className="text-muted-foreground">{t('settings.leftPane')}</span>
|
||||||
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -622,7 +619,7 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-xs">
|
<label className="flex flex-col gap-1 text-xs">
|
||||||
<span className="text-muted-foreground">Right pane</span>
|
<span className="text-muted-foreground">{t('settings.rightPane')}</span>
|
||||||
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ const en: Dict = {
|
|||||||
'offline.synced': '{n} QSO(s) added to the logbook',
|
'offline.synced': '{n} QSO(s) added to the logbook',
|
||||||
'offline.stillDown': 'Database still unreachable — your QSOs are safe',
|
'offline.stillDown': 'Database still unreachable — your QSOs are safe',
|
||||||
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
||||||
|
'settings.telemetry': 'Send anonymous usage statistics',
|
||||||
|
'settings.telemetryHint': 'install ID + version + OS, once a day — no callsign or QSO data',
|
||||||
|
'settings.liveStatus': 'Publish live operator status',
|
||||||
|
'settings.liveStatusHint': 'multi-op on shared MySQL — feeds a QRZ live page',
|
||||||
|
'settings.mainView': 'Main view',
|
||||||
|
'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).',
|
||||||
|
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
|
||||||
|
'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)',
|
||||||
|
'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before',
|
||||||
|
'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control',
|
||||||
|
'settings.pane.flex': 'FlexRadio controls', 'settings.pane.icom': 'Icom console',
|
||||||
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
|
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
|
||||||
'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
|
'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
|
||||||
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
|
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
|
||||||
@@ -359,6 +370,17 @@ const fr: Dict = {
|
|||||||
'offline.synced': '{n} QSO ajoutés au journal',
|
'offline.synced': '{n} QSO ajoutés au journal',
|
||||||
'offline.stillDown': 'Base toujours injoignable — tes QSO sont en sécurité',
|
'offline.stillDown': 'Base toujours injoignable — tes QSO sont en sécurité',
|
||||||
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
||||||
|
'settings.telemetry': "Envoyer des statistiques d'usage anonymes",
|
||||||
|
'settings.telemetryHint': "ID d'installation + version + OS, une fois par jour — aucun indicatif ni donnée QSO",
|
||||||
|
'settings.liveStatus': 'Publier le statut opérateur en direct',
|
||||||
|
'settings.liveStatusHint': 'multi-op sur MySQL partagé — alimente une page live QRZ',
|
||||||
|
'settings.mainView': 'Vue principale',
|
||||||
|
'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).",
|
||||||
|
'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
|
||||||
|
'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)',
|
||||||
|
'settings.pane.cluster': 'Spots cluster', 'settings.pane.worked': 'Déjà contactés',
|
||||||
|
'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net',
|
||||||
|
'settings.pane.flex': 'Contrôles FlexRadio', 'settings.pane.icom': 'Console Icom',
|
||||||
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
|
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
|
||||||
'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
|
'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
|
||||||
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
|
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
|
||||||
|
|||||||
Reference in New Issue
Block a user