rpi issue/ docker port

pull/49/head
accius 2 days ago
parent 82585acb3e
commit a94be88f0e

@ -48,6 +48,13 @@ THEME=dark
# Layout: 'modern' or 'classic' # Layout: 'modern' or 'classic'
LAYOUT=modern LAYOUT=modern
# Timezone: IANA timezone identifier
# Set this if your local time shows incorrectly (e.g. same as UTC).
# This is common with privacy browsers like Librewolf that spoof timezone.
# Examples: America/New_York, America/Regina, Europe/London, Asia/Tokyo
# Leave blank or commented out to use browser default.
# TZ=America/New_York
# =========================================== # ===========================================
# OPTIONAL - External Services # OPTIONAL - External Services
# =========================================== # ===========================================

@ -55,8 +55,9 @@ RUN chown -R openhamclock:nodejs /app
# Switch to non-root user # Switch to non-root user
USER openhamclock USER openhamclock
# Expose port # Expose ports (3000 = web, 2237 = WSJT-X UDP)
EXPOSE 3000 EXPOSE 3000
EXPOSE 2237/udp
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \

@ -6,9 +6,13 @@ services:
container_name: openhamclock container_name: openhamclock
ports: ports:
- "3000:3000" - "3000:3000"
- "2237:2237/udp" # WSJT-X UDP — point WSJT-X to 127.0.0.1:2237
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
# Uncomment and set your timezone (IANA format)
# This ensures correct local time display, especially with privacy browsers
# - TZ=America/New_York
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
@ -16,9 +20,6 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 10s start_period: 10s
# Uncomment to set timezone
# environment:
# - TZ=America/Denver
# For development with hot reload: # For development with hot reload:
# docker compose -f docker-compose.dev.yml up # docker compose -f docker-compose.dev.yml up

@ -3600,6 +3600,9 @@ app.get('/api/config', (req, res) => {
// Whether config is incomplete (show setup wizard) // Whether config is incomplete (show setup wizard)
configIncomplete: CONFIG.callsign === 'N0CALL' || !CONFIG.gridSquare, configIncomplete: CONFIG.callsign === 'N0CALL' || !CONFIG.gridSquare,
// Server timezone (from TZ env var or system)
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || '',
// Feature availability // Feature availability
features: { features: {
spaceWeather: true, spaceWeather: true,

@ -259,11 +259,18 @@ const App = () => {
setDxLocation({ lat: coords.lat, lon: coords.lon }); setDxLocation({ lat: coords.lat, lon: coords.lon });
}, []); }, []);
// Format times // Format times use explicit timezone if configured (fixes privacy browsers like Librewolf
// that spoof timezone to UTC via privacy.resistFingerprinting)
const utcTime = currentTime.toISOString().substr(11, 8); const utcTime = currentTime.toISOString().substr(11, 8);
const localTime = currentTime.toLocaleTimeString('en-US', { hour12: use12Hour });
const utcDate = currentTime.toISOString().substr(0, 10); const utcDate = currentTime.toISOString().substr(0, 10);
const localDate = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); const localTimeOpts = { hour12: use12Hour };
const localDateOpts = { weekday: 'short', month: 'short', day: 'numeric' };
if (config.timezone) {
localTimeOpts.timeZone = config.timezone;
localDateOpts.timeZone = config.timezone;
}
const localTime = currentTime.toLocaleTimeString('en-US', localTimeOpts);
const localDate = currentTime.toLocaleDateString('en-US', localDateOpts);
// Scale for small screens // Scale for small screens
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);

@ -1,7 +1,11 @@
/** /**
* PSKReporter Panel * PSKReporter + WSJT-X Panel
* Shows where your digital mode signals are being received * Digital mode spots toggle between internet (PSKReporter) and local (WSJT-X UDP)
* Toggles between PSKReporter (internet) and WSJT-X (local UDP) views *
* Layout:
* Row 1: Segmented mode toggle | map + filter controls
* Row 2: Sub-tabs (Being Heard / Hearing or Decodes / QSOs)
* Content: Scrolling spot/decode list
*/ */
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { usePSKReporter } from '../hooks/usePSKReporter.js'; import { usePSKReporter } from '../hooks/usePSKReporter.js';
@ -25,36 +29,28 @@ const PSKReporterPanel = ({
showWSJTXOnMap, showWSJTXOnMap,
onToggleWSJTXMap onToggleWSJTXMap
}) => { }) => {
const [panelMode, setPanelMode] = useState('psk'); // 'psk' | 'wsjtx' const [panelMode, setPanelMode] = useState('psk');
const [activeTab, setActiveTab] = useState('tx'); // PSK: tx | rx const [activeTab, setActiveTab] = useState('tx');
const [wsjtxTab, setWsjtxTab] = useState('decodes'); // WSJT-X: decodes | qsos const [wsjtxTab, setWsjtxTab] = useState('decodes');
const [bandFilter, setBandFilter] = useState('all'); const [wsjtxFilter, setWsjtxFilter] = useState('all'); // 'all' | 'cq' | band name
const [showCQ, setShowCQ] = useState(false);
// PSKReporter hook
const { const {
txReports, txReports, txCount, rxReports, rxCount,
txCount, loading, error, connected, source, refresh
rxReports,
rxCount,
loading,
error,
connected,
source,
refresh
} = usePSKReporter(callsign, { } = usePSKReporter(callsign, {
minutes: 15, minutes: 15,
enabled: callsign && callsign !== 'N0CALL' enabled: callsign && callsign !== 'N0CALL'
}); });
// PSK filter logic // PSK filtering
const filterReports = (reports) => { const filterReports = (reports) => {
return reports.filter(r => { return reports.filter(r => {
if (filters?.bands?.length && !filters.bands.includes(r.band)) return false; if (filters?.bands?.length && !filters.bands.includes(r.band)) return false;
if (filters?.grids?.length) { if (filters?.grids?.length) {
const grid = activeTab === 'tx' ? r.receiverGrid : r.senderGrid; const grid = activeTab === 'tx' ? r.receiverGrid : r.senderGrid;
if (!grid) return false; if (!grid) return false;
const gridPrefix = grid.substring(0, 2).toUpperCase(); if (!filters.grids.includes(grid.substring(0, 2).toUpperCase())) return false;
if (!filters.grids.includes(gridPrefix)) return false;
} }
if (filters?.modes?.length && !filters.modes.includes(r.mode)) return false; if (filters?.modes?.length && !filters.modes.includes(r.mode)) return false;
return true; return true;
@ -64,401 +60,378 @@ const PSKReporterPanel = ({
const filteredTx = useMemo(() => filterReports(txReports), [txReports, filters, activeTab]); const filteredTx = useMemo(() => filterReports(txReports), [txReports, filters, activeTab]);
const filteredRx = useMemo(() => filterReports(rxReports), [rxReports, filters, activeTab]); const filteredRx = useMemo(() => filterReports(rxReports), [rxReports, filters, activeTab]);
const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx; const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx;
const pskFilterCount = [filters?.bands?.length, filters?.grids?.length, filters?.modes?.length].filter(Boolean).length;
const getActiveFilterCount = () => { const getFreqColor = (freqMHz) => !freqMHz ? 'var(--text-muted)' : getBandColor(parseFloat(freqMHz));
let count = 0; const formatAge = (m) => m < 1 ? 'now' : m < 60 ? `${m}m` : `${Math.floor(m/60)}h`;
if (filters?.bands?.length) count++;
if (filters?.grids?.length) count++;
if (filters?.modes?.length) count++;
return count;
};
const filterCount = getActiveFilterCount();
const getFreqColor = (freqMHz) => {
if (!freqMHz) return 'var(--text-muted)';
return getBandColor(parseFloat(freqMHz));
};
const formatAge = (minutes) => {
if (minutes < 1) return 'now';
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes/60)}h`;
};
const getStatusIndicator = () => {
if (connected) return <span style={{ color: '#4ade80', fontSize: '10px' }}> LIVE</span>;
if (source === 'connecting' || source === 'reconnecting') return <span style={{ color: '#fbbf24', fontSize: '10px' }}> {source}</span>;
if (error) return <span style={{ color: '#ef4444', fontSize: '10px' }}> offline</span>;
return null;
};
// WSJT-X helpers // WSJT-X helpers
const activeClients = Object.entries(wsjtxClients); const activeClients = Object.entries(wsjtxClients);
const primaryClient = activeClients.length > 0 ? activeClients[0][1] : null; const primaryClient = activeClients[0]?.[1] || null;
const wsjtxBands = useMemo(() => { // Build unified filter options: All, CQ Only, then each available band
const bands = new Set(wsjtxDecodes.map(d => d.band).filter(Boolean)); const wsjtxFilterOptions = useMemo(() => {
return ['all', ...Array.from(bands).sort((a, b) => (parseInt(b) || 999) - (parseInt(a) || 999))]; const bands = [...new Set(wsjtxDecodes.map(d => d.band).filter(Boolean))]
.sort((a, b) => (parseInt(b) || 999) - (parseInt(a) || 999));
return [
{ value: 'all', label: 'All decodes' },
{ value: 'cq', label: 'CQ only' },
...bands.map(b => ({ value: b, label: b }))
];
}, [wsjtxDecodes]); }, [wsjtxDecodes]);
const filteredDecodes = useMemo(() => { const filteredDecodes = useMemo(() => {
let filtered = [...wsjtxDecodes]; let filtered = [...wsjtxDecodes];
if (bandFilter !== 'all') filtered = filtered.filter(d => d.band === bandFilter); if (wsjtxFilter === 'cq') {
if (showCQ) filtered = filtered.filter(d => d.type === 'CQ'); filtered = filtered.filter(d => d.type === 'CQ');
} else if (wsjtxFilter !== 'all') {
// Band filter
filtered = filtered.filter(d => d.band === wsjtxFilter);
}
return filtered.reverse(); return filtered.reverse();
}, [wsjtxDecodes, bandFilter, showCQ]); }, [wsjtxDecodes, wsjtxFilter]);
const getSnrColor = (snr) => { const getSnrColor = (snr) => {
if (snr === null || snr === undefined) return 'var(--text-muted)'; if (snr == null) return 'var(--text-muted)';
if (snr >= 0) return '#4ade80'; if (snr >= 0) return '#4ade80';
if (snr >= -10) return '#fbbf24'; if (snr >= -10) return '#fbbf24';
if (snr >= -18) return '#fb923c'; if (snr >= -18) return '#fb923c';
return '#ef4444'; return '#ef4444';
}; };
const getMsgColor = (decode) => { const getMsgColor = (d) => {
if (decode.type === 'CQ') return '#60a5fa'; if (d.type === 'CQ') return '#60a5fa';
if (decode.exchange === 'RR73' || decode.exchange === '73' || decode.exchange === 'RRR') return '#4ade80'; if (['RR73', '73', 'RRR'].includes(d.exchange)) return '#4ade80';
if (decode.exchange?.startsWith('R')) return '#fbbf24'; if (d.exchange?.startsWith('R')) return '#fbbf24';
return 'var(--text-primary)'; return 'var(--text-primary)';
}; };
// Mode switch button style // Active map toggle for current mode
const modeBtn = (mode, color) => ({ const isMapOn = panelMode === 'psk' ? showOnMap : showWSJTXOnMap;
padding: '2px 8px', const handleMapToggle = panelMode === 'psk' ? onToggleMap : onToggleWSJTXMap;
background: panelMode === mode ? `${color}22` : 'transparent',
border: `1px solid ${panelMode === mode ? color : 'var(--border-color)'}`, // Compact status dot
color: panelMode === mode ? color : 'var(--text-muted)', const statusDot = connected
? { color: '#4ade80', char: '●' }
: (source === 'connecting' || source === 'reconnecting')
? { color: '#fbbf24', char: '◐' }
: error ? { color: '#ef4444', char: '●' } : null;
// Shared styles
const segBtn = (active, color) => ({
padding: '3px 10px',
background: active ? `${color}18` : 'transparent',
color: active ? color : 'var(--text-muted)',
border: 'none',
borderBottom: active ? `2px solid ${color}` : '2px solid transparent',
fontSize: '11px',
fontWeight: active ? '700' : '400',
cursor: 'pointer',
letterSpacing: '0.02em',
});
const subTabBtn = (active, color) => ({
flex: 1,
padding: '3px 4px',
background: active ? `${color}20` : 'transparent',
border: `1px solid ${active ? color + '66' : 'var(--border-color)'}`,
borderRadius: '3px',
color: active ? color : 'var(--text-muted)',
cursor: 'pointer',
fontSize: '10px',
fontWeight: active ? '600' : '400',
});
const iconBtn = (active, activeColor = '#4488ff') => ({
background: active ? `${activeColor}30` : 'rgba(100,100,100,0.3)',
border: `1px solid ${active ? activeColor : '#555'}`,
color: active ? activeColor : '#777',
padding: '2px 6px',
borderRadius: '3px', borderRadius: '3px',
fontSize: '10px', fontSize: '10px',
cursor: 'pointer', cursor: 'pointer',
fontWeight: panelMode === mode ? '700' : '400', lineHeight: 1,
}); });
return ( return (
<div className="panel" style={{ <div className="panel" style={{
padding: '10px', padding: '8px 10px',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
height: '100%', height: '100%',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{/* Mode switcher header */} {/* ── Row 1: Mode toggle + controls ── */}
<div style={{ <div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: '4px', marginBottom: '5px',
flexShrink: 0 flexShrink: 0,
}}> }}>
<div style={{ display: 'flex', gap: '3px' }}> {/* Mode toggle */}
<button onClick={() => setPanelMode('psk')} style={modeBtn('psk', 'var(--accent-primary)')}> <div style={{ display: 'flex' }}>
📡 PSKReporter <button onClick={() => setPanelMode('psk')} style={segBtn(panelMode === 'psk', 'var(--accent-primary)')}>
PSKReporter
</button> </button>
<button onClick={() => setPanelMode('wsjtx')} style={modeBtn('wsjtx', '#a78bfa')}> <button onClick={() => setPanelMode('wsjtx')} style={segBtn(panelMode === 'wsjtx', '#a78bfa')}>
🔊 WSJT-X WSJT-X
</button> </button>
</div> </div>
{/* Controls row - differs per mode */} {/* Right controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{/* PSK: status dot + filter + refresh */}
{panelMode === 'psk' && ( {panelMode === 'psk' && (
<> <>
<span style={{ fontSize: '9px', color: 'var(--text-muted)' }}> {statusDot && (
{filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount} <span style={{ color: statusDot.color, fontSize: '10px', lineHeight: 1 }}>{statusDot.char}</span>
</span> )}
{getStatusIndicator()} <button onClick={onOpenFilters} style={iconBtn(pskFilterCount > 0, '#ffaa00')}>
<button onClick={onOpenFilters} style={{ {pskFilterCount > 0 ? `🔍${pskFilterCount}` : '🔍'}
background: filterCount > 0 ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)', </button>
border: `1px solid ${filterCount > 0 ? '#ffaa00' : '#666'}`,
color: filterCount > 0 ? '#ffaa00' : '#888',
padding: '2px 6px', borderRadius: '4px', fontSize: '10px', cursor: 'pointer'
}}>🔍</button>
<button onClick={refresh} disabled={loading} style={{ <button onClick={refresh} disabled={loading} style={{
background: 'rgba(100, 100, 100, 0.3)', border: '1px solid #666', ...iconBtn(false),
color: '#888', padding: '2px 6px', borderRadius: '4px', fontSize: '10px', opacity: loading ? 0.4 : 1,
cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.5 : 1 cursor: loading ? 'not-allowed' : 'pointer',
}}>🔄</button> }}>🔄</button>
{onToggleMap && (
<button onClick={onToggleMap} style={{
background: showOnMap ? 'rgba(68, 136, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${showOnMap ? '#4488ff' : '#666'}`,
color: showOnMap ? '#4488ff' : '#888',
padding: '2px 6px', borderRadius: '4px', fontSize: '10px', cursor: 'pointer'
}}>🗺 {showOnMap ? 'ON' : 'OFF'}</button>
)}
</> </>
)} )}
{/* WSJT-X: mode/band info + unified filter */}
{panelMode === 'wsjtx' && ( {panelMode === 'wsjtx' && (
<> <>
{primaryClient && ( {primaryClient && (
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}> <span style={{ fontSize: '9px', color: 'var(--text-muted)' }}>
{primaryClient.mode || ''} {primaryClient.band || ''} {primaryClient.mode} {primaryClient.band}
{primaryClient.transmitting && <span style={{ color: '#ef4444', marginLeft: '3px' }}>TX</span>} {primaryClient.transmitting && <span style={{ color: '#ef4444', marginLeft: '2px' }}>TX</span>}
{primaryClient.decoding && <span style={{ color: '#4ade80', marginLeft: '3px' }}>RX</span>}
</span> </span>
)} )}
<select value={bandFilter} onChange={(e) => setBandFilter(e.target.value)} style={{ <select
background: 'var(--bg-tertiary)', color: 'var(--text-primary)', value={wsjtxFilter}
border: '1px solid var(--border-color)', borderRadius: '3px', onChange={(e) => setWsjtxFilter(e.target.value)}
fontSize: '10px', padding: '1px 2px', cursor: 'pointer' style={{
}}> background: 'var(--bg-tertiary)',
{wsjtxBands.map(b => <option key={b} value={b}>{b === 'all' ? 'All' : b}</option>)} color: wsjtxFilter !== 'all' ? '#a78bfa' : 'var(--text-primary)',
border: `1px solid ${wsjtxFilter !== 'all' ? '#a78bfa55' : 'var(--border-color)'}`,
borderRadius: '3px',
fontSize: '10px',
padding: '1px 4px',
cursor: 'pointer',
maxWidth: '90px',
}}
>
{wsjtxFilterOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select> </select>
<button onClick={() => setShowCQ(!showCQ)} style={{
background: showCQ ? '#60a5fa33' : 'transparent',
color: showCQ ? '#60a5fa' : 'var(--text-muted)',
border: `1px solid ${showCQ ? '#60a5fa55' : 'var(--border-color)'}`,
borderRadius: '3px', fontSize: '10px', padding: '1px 4px', cursor: 'pointer'
}}>CQ</button>
{onToggleWSJTXMap && (
<button onClick={onToggleWSJTXMap} style={{
background: showWSJTXOnMap ? 'rgba(167, 139, 250, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${showWSJTXOnMap ? '#a78bfa' : '#666'}`,
color: showWSJTXOnMap ? '#a78bfa' : '#888',
padding: '2px 6px', borderRadius: '4px', fontSize: '10px', cursor: 'pointer'
}}>🗺 {showWSJTXOnMap ? 'ON' : 'OFF'}</button>
)}
</> </>
)} )}
{/* Map toggle (always visible) */}
{handleMapToggle && (
<button onClick={handleMapToggle} style={iconBtn(isMapOn, panelMode === 'psk' ? '#4488ff' : '#a78bfa')}>
🗺
</button>
)}
</div> </div>
</div> </div>
{/* === PSKReporter View === */} {/* ── Row 2: Sub-tabs ── */}
{panelMode === 'psk' && ( <div style={{ display: 'flex', gap: '4px', marginBottom: '5px', flexShrink: 0 }}>
<> {panelMode === 'psk' ? (
{(!callsign || callsign === 'N0CALL') ? ( <>
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '10px', fontSize: '11px' }}> <button onClick={() => setActiveTab('tx')} style={subTabBtn(activeTab === 'tx', '#4ade80')}>
Set callsign in Settings Heard ({pskFilterCount > 0 ? filteredTx.length : txCount})
</div> </button>
) : ( <button onClick={() => setActiveTab('rx')} style={subTabBtn(activeTab === 'rx', '#60a5fa')}>
<> Hearing ({pskFilterCount > 0 ? filteredRx.length : rxCount})
{/* PSK Tabs */} </button>
<div style={{ display: 'flex', gap: '4px', marginBottom: '6px', flexShrink: 0 }}> </>
<button onClick={() => setActiveTab('tx')} style={{ ) : (
flex: 1, padding: '4px 6px', <>
background: activeTab === 'tx' ? 'rgba(74, 222, 128, 0.2)' : 'rgba(100, 100, 100, 0.2)', <button onClick={() => setWsjtxTab('decodes')} style={subTabBtn(wsjtxTab === 'decodes', '#a78bfa')}>
border: `1px solid ${activeTab === 'tx' ? '#4ade80' : '#555'}`, Decodes ({filteredDecodes.length})
borderRadius: '3px', color: activeTab === 'tx' ? '#4ade80' : '#888', </button>
cursor: 'pointer', fontSize: '10px', fontFamily: 'JetBrains Mono' <button onClick={() => setWsjtxTab('qsos')} style={subTabBtn(wsjtxTab === 'qsos', '#a78bfa')}>
}}> QSOs ({wsjtxQsos.length})
📤 Being Heard ({filterCount > 0 ? filteredTx.length : txCount}) </button>
</button> </>
<button onClick={() => setActiveTab('rx')} style={{ )}
flex: 1, padding: '4px 6px', </div>
background: activeTab === 'rx' ? 'rgba(96, 165, 250, 0.2)' : 'rgba(100, 100, 100, 0.2)',
border: `1px solid ${activeTab === 'rx' ? '#60a5fa' : '#555'}`,
borderRadius: '3px', color: activeTab === 'rx' ? '#60a5fa' : '#888',
cursor: 'pointer', fontSize: '10px', fontFamily: 'JetBrains Mono'
}}>
📥 Hearing ({filterCount > 0 ? filteredRx.length : rxCount})
</button>
</div>
{/* PSK Reports list */} {/* ── Content area ── */}
{error && !connected ? ( <div style={{ flex: 1, overflow: 'auto', fontSize: '11px', fontFamily: "'JetBrains Mono', monospace" }}>
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
Connection failed - click 🔄 to retry
</div>
) : loading && filteredReports.length === 0 && filterCount === 0 ? (
<div style={{ textAlign: 'center', padding: '15px', color: 'var(--text-muted)', fontSize: '11px' }}>
<div className="loading-spinner" style={{ margin: '0 auto 8px' }} />
Connecting to MQTT...
</div>
) : !connected && filteredReports.length === 0 && filterCount === 0 ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
Waiting for connection...
</div>
) : filteredReports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
{filterCount > 0
? 'No spots match filters'
: activeTab === 'tx'
? 'Waiting for spots... (TX to see reports)'
: 'No stations heard yet'}
</div>
) : (
<div style={{ flex: 1, overflow: 'auto', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace' }}>
{filteredReports.slice(0, 20).map((report, i) => {
const freqMHz = report.freqMHz || (report.freq ? (report.freq / 1000000).toFixed(3) : '?');
const color = getFreqColor(freqMHz);
const displayCall = activeTab === 'tx' ? report.receiver : report.sender;
const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid;
return ( {/* === PSKReporter content === */}
<div {panelMode === 'psk' && (
key={`${displayCall}-${report.freq}-${i}`} <>
onClick={() => onShowOnMap && report.lat && report.lon && onShowOnMap(report)} {(!callsign || callsign === 'N0CALL') ? (
style={{ <div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '11px' }}>
display: 'grid', gridTemplateColumns: '55px 1fr auto', Set your callsign in Settings to see reports
gap: '6px', padding: '4px 6px', borderRadius: '3px', marginBottom: '2px', </div>
background: i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent', ) : error && !connected ? (
cursor: report.lat && report.lon ? 'pointer' : 'default', <div style={{ textAlign: 'center', padding: '12px', color: 'var(--text-muted)', fontSize: '11px' }}>
transition: 'background 0.15s', borderLeft: '2px solid transparent' Connection failed tap 🔄
}} </div>
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68, 136, 255, 0.15)'} ) : loading && filteredReports.length === 0 && pskFilterCount === 0 ? (
onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent'} <div style={{ textAlign: 'center', padding: '16px', color: 'var(--text-muted)', fontSize: '11px' }}>
> <div className="loading-spinner" style={{ margin: '0 auto 8px' }} />
<div style={{ color, fontWeight: '600', fontSize: '11px' }}>{freqMHz}</div> Connecting...
<div style={{ color: 'var(--text-primary)', fontWeight: '600', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '11px' }}> </div>
{displayCall} ) : filteredReports.length === 0 ? (
{grid && <span style={{ color: 'var(--text-muted)', fontWeight: '400', marginLeft: '4px', fontSize: '9px' }}>{grid}</span>} <div style={{ textAlign: 'center', padding: '12px', color: 'var(--text-muted)', fontSize: '11px' }}>
</div> {pskFilterCount > 0
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '10px' }}> ? 'No spots match filters'
<span style={{ color: 'var(--text-muted)' }}>{report.mode}</span> : activeTab === 'tx'
{report.snr !== null && report.snr !== undefined && ( ? 'Waiting for spots... (TX to see reports)'
<span style={{ color: report.snr >= 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', fontWeight: '600' }}> : 'No stations heard yet'}
{report.snr > 0 ? '+' : ''}{report.snr} </div>
</span> ) : (
)} filteredReports.slice(0, 25).map((report, i) => {
<span style={{ color: 'var(--text-muted)', fontSize: '9px' }}>{formatAge(report.age)}</span> const freqMHz = report.freqMHz || (report.freq ? (report.freq / 1000000).toFixed(3) : '?');
</div> const color = getFreqColor(freqMHz);
</div> const displayCall = activeTab === 'tx' ? report.receiver : report.sender;
); const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid;
})}
</div>
)}
</>
)}
</>
)}
{/* === WSJT-X View === */} return (
{panelMode === 'wsjtx' && ( <div
<> key={`${displayCall}-${report.freq}-${i}`}
{/* WSJT-X Tabs */} onClick={() => onShowOnMap?.(report)}
<div style={{ display: 'flex', gap: '2px', marginBottom: '4px', flexShrink: 0 }}> style={{
{[ display: 'grid',
{ key: 'decodes', label: `Decodes (${wsjtxDecodes.length})` }, gridTemplateColumns: '52px 1fr auto',
{ key: 'qsos', label: `QSOs (${wsjtxQsos.length})` }, gap: '5px',
].map(tab => ( padding: '3px 4px',
<button borderRadius: '2px',
key={tab.key} background: i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent',
onClick={() => setWsjtxTab(tab.key)} cursor: report.lat && report.lon ? 'pointer' : 'default',
style={{ }}
flex: 1, onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68,136,255,0.12)'}
background: wsjtxTab === tab.key ? 'var(--bg-tertiary)' : 'transparent', onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'}
color: wsjtxTab === tab.key ? '#a78bfa' : 'var(--text-muted)', >
border: 'none', <span style={{ color, fontWeight: '600', fontSize: '10px' }}>{freqMHz}</span>
borderBottom: wsjtxTab === tab.key ? '2px solid #a78bfa' : '2px solid transparent', <span style={{
fontSize: '10px', padding: '3px 6px', cursor: 'pointer', color: 'var(--text-primary)', fontWeight: '600', fontSize: '11px',
borderRadius: '3px 3px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'
}} }}>
> {displayCall}
{tab.label} {grid && <span style={{ color: 'var(--text-muted)', fontWeight: '400', marginLeft: '4px', fontSize: '9px' }}>{grid}</span>}
</button> </span>
))} <span style={{ display: 'flex', alignItems: 'center', gap: '3px', fontSize: '9px' }}>
</div> <span style={{ color: 'var(--text-muted)' }}>{report.mode}</span>
{report.snr != null && (
<span style={{ color: report.snr >= 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', fontWeight: '600' }}>
{report.snr > 0 ? '+' : ''}{report.snr}
</span>
)}
<span style={{ color: 'var(--text-muted)' }}>{formatAge(report.age)}</span>
</span>
</div>
);
})
)}
</>
)}
{/* No WSJT-X connected */} {/* === WSJT-X content === */}
{!wsjtxLoading && activeClients.length === 0 && wsjtxDecodes.length === 0 ? ( {panelMode === 'wsjtx' && (
<div style={{ <>
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', {/* No client connected */}
flexDirection: 'column', gap: '6px', color: 'var(--text-muted)', {!wsjtxLoading && activeClients.length === 0 && wsjtxDecodes.length === 0 ? (
fontSize: '11px', textAlign: 'center', padding: '8px'
}}>
<div>Waiting for WSJT-X...</div>
<div style={{ fontSize: '10px', opacity: 0.7 }}>
Settings Reporting UDP Server
<br />
Address: {'{server IP}'} &nbsp; Port: {wsjtxPort || 2237}
</div>
</div>
) : (
<>
{/* Decodes / QSOs content */}
<div style={{ <div style={{
flex: 1, overflowY: 'auto', overflowX: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '11px', fontFamily: "'JetBrains Mono', 'Fira Code', monospace", flexDirection: 'column', gap: '8px', color: 'var(--text-muted)',
fontSize: '11px', textAlign: 'center', padding: '16px 8px', height: '100%'
}}> }}>
{wsjtxTab === 'decodes' && ( <div style={{ fontSize: '12px' }}>Waiting for WSJT-X...</div>
<> <div style={{ fontSize: '10px', opacity: 0.6, lineHeight: 1.5 }}>
{filteredDecodes.length === 0 ? ( In WSJT-X: Settings Reporting UDP Server
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '20px', fontSize: '11px' }}> <br />
{wsjtxDecodes.length > 0 ? 'No decodes match filter' : 'Listening for decodes...'} Address: 127.0.0.1 &nbsp; Port: {wsjtxPort || 2237}
</div> </div>
) : ( </div>
filteredDecodes.map((d, i) => ( ) : wsjtxTab === 'decodes' ? (
<div <>
key={d.id || i} {filteredDecodes.length === 0 ? (
style={{ <div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '11px' }}>
display: 'flex', gap: '6px', padding: '2px 0', {wsjtxDecodes.length > 0 ? 'No decodes match filter' : 'Listening...'}
borderBottom: '1px solid var(--border-color)', </div>
alignItems: 'baseline', ) : (
opacity: d.lowConfidence ? 0.6 : 1, filteredDecodes.map((d, i) => (
}} <div
> key={d.id || i}
<span style={{ color: 'var(--text-muted)', minWidth: '48px', fontSize: '10px' }}>{d.time}</span> style={{
<span style={{ color: getSnrColor(d.snr), minWidth: '28px', textAlign: 'right', fontSize: '10px' }}> display: 'flex', gap: '5px', padding: '2px 2px',
{d.snr != null ? (d.snr >= 0 ? `+${d.snr}` : d.snr) : ''} borderBottom: '1px solid var(--border-color)',
</span> alignItems: 'baseline',
<span style={{ color: 'var(--text-muted)', minWidth: '28px', textAlign: 'right', fontSize: '10px' }}>{d.dt}</span> opacity: d.lowConfidence ? 0.5 : 1,
<span style={{ }}
color: d.band ? getBandColor(d.dialFrequency / 1000000) : 'var(--text-muted)', >
minWidth: '36px', textAlign: 'right', fontSize: '10px' <span style={{ color: 'var(--text-muted)', minWidth: '44px', fontSize: '10px' }}>{d.time}</span>
}}>{d.freq}</span> <span style={{ color: getSnrColor(d.snr), minWidth: '26px', textAlign: 'right', fontSize: '10px' }}>
<span style={{ {d.snr != null ? (d.snr >= 0 ? `+${d.snr}` : d.snr) : ''}
color: getMsgColor(d), flex: 1, whiteSpace: 'nowrap', </span>
overflow: 'hidden', textOverflow: 'ellipsis', <span style={{ color: 'var(--text-muted)', minWidth: '24px', textAlign: 'right', fontSize: '10px' }}>{d.dt}</span>
}}>{d.message}</span> <span style={{
</div> color: d.band ? getBandColor(d.dialFrequency / 1000000) : 'var(--text-muted)',
)) minWidth: '32px', textAlign: 'right', fontSize: '10px'
)} }}>{d.freq}</span>
</> <span style={{
color: getMsgColor(d), flex: 1, whiteSpace: 'nowrap',
overflow: 'hidden', textOverflow: 'ellipsis',
}}>{d.message}</span>
</div>
))
)} )}
</>
{wsjtxTab === 'qsos' && ( ) : (
<> /* QSOs tab */
{wsjtxQsos.length === 0 ? ( <>
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '20px', fontSize: '11px' }}> {wsjtxQsos.length === 0 ? (
No QSOs logged yet <div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '11px' }}>
</div> No QSOs logged yet
) : ( </div>
[...wsjtxQsos].reverse().map((q, i) => ( ) : (
<div key={i} style={{ [...wsjtxQsos].reverse().map((q, i) => (
display: 'flex', gap: '6px', padding: '3px 0', <div key={i} style={{
borderBottom: '1px solid var(--border-color)', alignItems: 'baseline', display: 'flex', gap: '5px', padding: '3px 2px',
}}> borderBottom: '1px solid var(--border-color)', alignItems: 'baseline',
<span style={{ color: q.band ? getBandColor(q.frequency / 1000000) : 'var(--accent-green)', fontWeight: '600', minWidth: '70px' }}> }}>
{q.dxCall} <span style={{
</span> color: q.band ? getBandColor(q.frequency / 1000000) : 'var(--accent-green)',
<span style={{ color: 'var(--text-muted)', minWidth: '40px', fontSize: '10px' }}>{q.band}</span> fontWeight: '600', minWidth: '65px'
<span style={{ color: 'var(--text-muted)', minWidth: '30px', fontSize: '10px' }}>{q.mode}</span> }}>{q.dxCall}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.reportSent}/{q.reportRecv}</span> <span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.band}</span>
{q.dxGrid && <span style={{ color: '#a78bfa', fontSize: '10px' }}>{q.dxGrid}</span>} <span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.mode}</span>
</div> <span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.reportSent}/{q.reportRecv}</span>
)) {q.dxGrid && <span style={{ color: '#a78bfa', fontSize: '10px' }}>{q.dxGrid}</span>}
)} </div>
</> ))
)} )}
</div> </>
)}
</>
)}
</div>
{/* WSJT-X status bar */} {/* ── WSJT-X status footer ── */}
{activeClients.length > 0 && ( {panelMode === 'wsjtx' && activeClients.length > 0 && (
<div style={{ <div style={{
fontSize: '9px', color: 'var(--text-muted)', fontSize: '9px', color: 'var(--text-muted)',
borderTop: '1px solid var(--border-color)', borderTop: '1px solid var(--border-color)',
paddingTop: '3px', marginTop: '3px', paddingTop: '2px', marginTop: '2px',
display: 'flex', justifyContent: 'space-between', flexShrink: 0 display: 'flex', justifyContent: 'space-between', flexShrink: 0
}}> }}>
<span> <span>{activeClients.map(([id, c]) => `${id}${c.version ? ` v${c.version}` : ''}`).join(', ')}</span>
{activeClients.map(([id, c]) => `${id}${c.version ? ` v${c.version}` : ''}`).join(', ')} {primaryClient?.dialFrequency && (
</span> <span style={{ color: '#a78bfa' }}>{(primaryClient.dialFrequency / 1000000).toFixed(6)} MHz</span>
{primaryClient?.dialFrequency && (
<span style={{ color: '#a78bfa' }}>
{(primaryClient.dialFrequency / 1000000).toFixed(6)} MHz
</span>
)}
</div>
)}
</>
)} )}
</> </div>
)} )}
</div> </div>
); );
}; };
export default PSKReporterPanel; export default PSKReporterPanel;
export { PSKReporterPanel }; export { PSKReporterPanel };

@ -14,6 +14,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [lon, setLon] = useState(config?.location?.lon || 0); const [lon, setLon] = useState(config?.location?.lon || 0);
const [theme, setTheme] = useState(config?.theme || 'dark'); const [theme, setTheme] = useState(config?.theme || 'dark');
const [layout, setLayout] = useState(config?.layout || 'modern'); const [layout, setLayout] = useState(config?.layout || 'modern');
const [timezone, setTimezone] = useState(config?.timezone || '');
const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy'); const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy');
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@ -28,6 +29,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
setLon(config.location?.lon || 0); setLon(config.location?.lon || 0);
setTheme(config.theme || 'dark'); setTheme(config.theme || 'dark');
setLayout(config.layout || 'modern'); setLayout(config.layout || 'modern');
setTimezone(config.timezone || '');
setDxClusterSource(config.dxClusterSource || 'dxspider-proxy'); setDxClusterSource(config.dxClusterSource || 'dxspider-proxy');
if (config.location?.lat && config.location?.lon) { if (config.location?.lat && config.location?.lon) {
setGridSquare(calculateGridSquare(config.location.lat, config.location.lon)); setGridSquare(calculateGridSquare(config.location.lat, config.location.lon));
@ -148,6 +150,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
location: { lat: parseFloat(lat), lon: parseFloat(lon) }, location: { lat: parseFloat(lat), lon: parseFloat(lon) },
theme, theme,
layout, layout,
timezone,
dxClusterSource dxClusterSource
}); });
onClose(); onClose();
@ -451,6 +454,112 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
</div> </div>
{/* DX Cluster Source */} {/* DX Cluster Source */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
🕐 Timezone
</label>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
style={{
width: '100%',
padding: '12px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
color: timezone ? 'var(--accent-green)' : 'var(--text-muted)',
fontSize: '14px',
fontFamily: 'JetBrains Mono, monospace',
cursor: 'pointer'
}}
>
<option value="">Auto (browser default)</option>
<optgroup label="North America">
<option value="America/New_York">Eastern (New York)</option>
<option value="America/Chicago">Central (Chicago)</option>
<option value="America/Denver">Mountain (Denver)</option>
<option value="America/Los_Angeles">Pacific (Los Angeles)</option>
<option value="America/Anchorage">Alaska</option>
<option value="Pacific/Honolulu">Hawaii</option>
<option value="America/Phoenix">Arizona (no DST)</option>
<option value="America/Regina">Saskatchewan (no DST)</option>
<option value="America/Halifax">Atlantic (Halifax)</option>
<option value="America/St_Johns">Newfoundland</option>
<option value="America/Toronto">Ontario (Toronto)</option>
<option value="America/Winnipeg">Manitoba (Winnipeg)</option>
<option value="America/Edmonton">Alberta (Edmonton)</option>
<option value="America/Vancouver">BC (Vancouver)</option>
<option value="America/Mexico_City">Mexico City</option>
</optgroup>
<optgroup label="Europe">
<option value="Europe/London">UK (London)</option>
<option value="Europe/Dublin">Ireland (Dublin)</option>
<option value="Europe/Paris">Central Europe (Paris)</option>
<option value="Europe/Berlin">Germany (Berlin)</option>
<option value="Europe/Rome">Italy (Rome)</option>
<option value="Europe/Madrid">Spain (Madrid)</option>
<option value="Europe/Amsterdam">Netherlands (Amsterdam)</option>
<option value="Europe/Brussels">Belgium (Brussels)</option>
<option value="Europe/Stockholm">Sweden (Stockholm)</option>
<option value="Europe/Helsinki">Finland (Helsinki)</option>
<option value="Europe/Athens">Greece (Athens)</option>
<option value="Europe/Bucharest">Romania (Bucharest)</option>
<option value="Europe/Moscow">Russia (Moscow)</option>
<option value="Europe/Warsaw">Poland (Warsaw)</option>
<option value="Europe/Zurich">Switzerland (Zurich)</option>
<option value="Europe/Lisbon">Portugal (Lisbon)</option>
</optgroup>
<optgroup label="Asia & Pacific">
<option value="Asia/Tokyo">Japan (Tokyo)</option>
<option value="Asia/Seoul">Korea (Seoul)</option>
<option value="Asia/Shanghai">China (Shanghai)</option>
<option value="Asia/Hong_Kong">Hong Kong</option>
<option value="Asia/Taipei">Taiwan (Taipei)</option>
<option value="Asia/Singapore">Singapore</option>
<option value="Asia/Kolkata">India (Kolkata)</option>
<option value="Asia/Dubai">UAE (Dubai)</option>
<option value="Asia/Riyadh">Saudi Arabia (Riyadh)</option>
<option value="Asia/Tehran">Iran (Tehran)</option>
<option value="Asia/Bangkok">Thailand (Bangkok)</option>
<option value="Asia/Jakarta">Indonesia (Jakarta)</option>
<option value="Asia/Manila">Philippines (Manila)</option>
<option value="Australia/Sydney">Australia Eastern (Sydney)</option>
<option value="Australia/Adelaide">Australia Central (Adelaide)</option>
<option value="Australia/Perth">Australia Western (Perth)</option>
<option value="Pacific/Auckland">New Zealand (Auckland)</option>
<option value="Pacific/Fiji">Fiji</option>
</optgroup>
<optgroup label="South America">
<option value="America/Sao_Paulo">Brazil (São Paulo)</option>
<option value="America/Argentina/Buenos_Aires">Argentina (Buenos Aires)</option>
<option value="America/Santiago">Chile (Santiago)</option>
<option value="America/Bogota">Colombia (Bogotá)</option>
<option value="America/Lima">Peru (Lima)</option>
<option value="America/Caracas">Venezuela (Caracas)</option>
</optgroup>
<optgroup label="Africa">
<option value="Africa/Cairo">Egypt (Cairo)</option>
<option value="Africa/Johannesburg">South Africa (Johannesburg)</option>
<option value="Africa/Lagos">Nigeria (Lagos)</option>
<option value="Africa/Nairobi">Kenya (Nairobi)</option>
<option value="Africa/Casablanca">Morocco (Casablanca)</option>
</optgroup>
<optgroup label="Other">
<option value="UTC">UTC</option>
<option value="Atlantic/Reykjavik">Iceland (Reykjavik)</option>
<option value="Atlantic/Azores">Azores</option>
<option value="Indian/Maldives">Maldives</option>
<option value="Indian/Mauritius">Mauritius</option>
</optgroup>
</select>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
Set this if your local time shows incorrectly (e.g. same as UTC).
Privacy browsers like Librewolf may spoof your timezone.
{timezone ? '' : ' Currently using browser default.'}
</div>
</div>
{/* DX Cluster Source - original */}
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}> <label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
{t('station.settings.dx.title')} {t('station.settings.dx.title')}

@ -16,6 +16,7 @@ export const DEFAULT_CONFIG = {
units: 'imperial', // 'imperial' or 'metric' units: 'imperial', // 'imperial' or 'metric'
theme: 'dark', // 'dark', 'light', 'legacy', or 'retro' theme: 'dark', // 'dark', 'light', 'legacy', or 'retro'
layout: 'modern', // 'modern' or 'classic' layout: 'modern', // 'modern' or 'classic'
timezone: '', // IANA timezone (e.g. 'America/Regina') — empty = browser default
use12Hour: true, use12Hour: true,
showSatellites: true, showSatellites: true,
showPota: true, showPota: true,
@ -104,6 +105,7 @@ export const loadConfig = () => {
units: serverConfig.units || config.units, units: serverConfig.units || config.units,
theme: serverConfig.theme || config.theme, theme: serverConfig.theme || config.theme,
layout: serverConfig.layout || config.layout, layout: serverConfig.layout || config.layout,
timezone: serverConfig.timezone || config.timezone,
use12Hour: serverConfig.timeFormat === '12', use12Hour: serverConfig.timeFormat === '12',
showSatellites: serverConfig.showSatellites ?? config.showSatellites, showSatellites: serverConfig.showSatellites ?? config.showSatellites,
showPota: serverConfig.showPota ?? config.showPota, showPota: serverConfig.showPota ?? config.showPota,

Loading…
Cancel
Save

Powered by TurnKey Linux.