From d26e25275b027c5bd9b7adf22ed6d62f80bc2a25 Mon Sep 17 00:00:00 2001 From: Delerius Date: Sun, 15 Feb 2026 13:45:43 -0800 Subject: [PATCH] Stabilize Rotator + N3FJP integrations (local-only UX + safety fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This update stabilizes the Rotator and N3FJP integrations and improves local-only UX clarity. Rotator: - Explicitly treated as a local-only feature - Hidden/disabled in hosted deployments - Added Integrations β†’ Local-only setup section - Added quick start instructions - Prevented runtime errors when backend is unavailable - Ensured overlay state does not auto-enable unexpectedly N3FJP: - Cleaned up on-map controls (moved configuration to Integrations) - Added display window options up to 24 hours - Added server retention configuration support via .env - Auto-enable map layer when integration is enabled - Added warning if layer is disabled - Fixed rendering crash in display window selector Server: - Support for N3FJP_QSO_RETENTION_MINUTES in .env and .env.example This ensures hosted OHC never crashes, while local installations gain full functionality with clear setup guidance. --- .env.example | 7 + server.js | 2 +- src/DockableApp.jsx | 93 ++++- src/components/SettingsPanel.jsx | 469 +++++++++++++++++++++++ src/components/WorldMap.jsx | 35 +- src/plugins/layers/useN3FJPLoggedQSOs.js | 117 ++---- 6 files changed, 621 insertions(+), 102 deletions(-) diff --git a/.env.example b/.env.example index ad63e0bf..c9fefd54 100644 --- a/.env.example +++ b/.env.example @@ -231,3 +231,10 @@ VITE_PSTROTATOR_BASE_URL=/pstrotator # Optional: HTTP endpoint for PstRotatorAz web interface (for proxy) # Set this to the machine running PstRotatorAz (default shown below) VITE_PSTROTATOR_TARGET=http://192.168.1.43:50004 +# =========================================== +# N3FJP QSO RETENTION +# =========================================== + +# How long the server keeps logged QSOs in memory (minutes) +# 1440 = 24 hours +N3FJP_QSO_RETENTION_MINUTES=1440 \ No newline at end of file diff --git a/server.js b/server.js index ec10c4f1..d094a874 100644 --- a/server.js +++ b/server.js @@ -8613,7 +8613,7 @@ function handleWSJTXMessage(msg, state) { } // ---- N3FJP Logged QSO relay (in-memory) ---- -const N3FJP_QSO_RETENTION_MINUTES = parseInt(process.env.N3FJP_QSO_RETENTION_MINUTES || "15", 10); +const N3FJP_QSO_RETENTION_MINUTES = parseInt(process.env.N3FJP_QSO_RETENTION_MINUTES || "1440", 10); let n3fjpQsos = []; function pruneN3fjpQsos() { diff --git a/src/DockableApp.jsx b/src/DockableApp.jsx index 8e9ffbdd..91609d41 100644 --- a/src/DockableApp.jsx +++ b/src/DockableApp.jsx @@ -30,6 +30,7 @@ import { DockableLayoutProvider } from './contexts'; import './styles/flexlayout-openhamclock.css'; import useMapLayers from './hooks/app/useMapLayers'; import useRotator from "./hooks/useRotator"; +import useLocalInstall from './hooks/app/useLocalInstall'; // Icons const PlusIcon = () => ( @@ -143,6 +144,37 @@ export const DockableApp = ({ const toggleWSJTXEff = useInternalMapLayers ? internalMap.toggleWSJTX : toggleWSJTX; const toggleRotatorBearingEff = useInternalMapLayers ? internalMap.toggleRotatorBearing : toggleRotatorBearing; + // Rotator is a local-only feature and must never break hosted deployments. + const isLocalInstallHook = useLocalInstall(); + const isLocal = (typeof isLocalInstall === "boolean") ? isLocalInstall : isLocalInstallHook; + + const [rotatorFeatureEnabled, setRotatorFeatureEnabled] = useState(() => { + try { + return localStorage.getItem('ohc_rotator_enabled') === '1'; + } catch { + return false; + } + }); + + // Allow SettingsPanel (and other UI) to toggle rotator via localStorage. + useEffect(() => { + const onChange = () => { + try { + setRotatorFeatureEnabled(localStorage.getItem('ohc_rotator_enabled') === '1'); + } catch { + setRotatorFeatureEnabled(false); + } + }; + window.addEventListener('storage', onChange); + window.addEventListener('ohc-rotator-config-changed', onChange); + return () => { + window.removeEventListener('storage', onChange); + window.removeEventListener('ohc-rotator-config-changed', onChange); + }; + }, []); + + const rotatorEnabled = isLocal && rotatorFeatureEnabled; + // Per-panel zoom levels (persisted) const [panelZoom, setPanelZoom] = useState(() => { try { @@ -201,6 +233,8 @@ export const DockableApp = ({ } catch { return false; } })(); + const showRotator = true; + return { 'world-map': { name: 'World Map', icon: 'πŸ—ΊοΈ' }, 'de-location': { name: 'DE Location', icon: 'πŸ“' }, @@ -221,12 +255,12 @@ export const DockableApp = ({ 'dxpeditions': { name: 'DXpeditions', icon: '🏝️' }, 'pota': { name: 'POTA', icon: 'πŸ•οΈ' }, 'sota': { name: 'SOTA', icon: '⛰️' }, - 'rotator': { name: 'Rotator', icon: '🧭' }, + ...(showRotator ? { 'rotator': { name: 'Rotator', icon: '🧭' } } : {}), 'contests': { name: 'Contests', icon: 'πŸ†' }, ...(hasAmbient ? { 'ambient': { name: 'Ambient Weather', icon: '🌦️' } } : {}), 'id-timer': { name: 'ID Timer', icon: 'πŸ“’' }, }; - }, []); + }, [rotatorEnabled]); // Add panel const handleAddPanel = useCallback((panelId) => { @@ -311,12 +345,15 @@ export const DockableApp = ({ ); const rot = useRotator({ - mock: false, - endpointUrl: "/api/rotator/status", - pollMs: 1000, - staleMs: 5000, -}); + enabled: rotatorEnabled, + mock: false, + endpointUrl: "/api/rotator/status", + pollMs: 1000, + staleMs: 5000, + }); + const turnRotator = useCallback(async (azimuth) => { + if (!rotatorEnabled) return { ok: false, error: 'rotator disabled' }; const res = await fetch("/api/rotator/turn", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -327,16 +364,17 @@ export const DockableApp = ({ throw new Error(data?.error || `HTTP ${res.status}`); } return data; - }, []); + }, [rotatorEnabled]); const stopRotator = useCallback(async () => { + if (!rotatorEnabled) return { ok: false, error: 'rotator disabled' }; const res = await fetch("/api/rotator/stop", { method: "POST" }); const data = await res.json().catch(() => ({})); if (!res.ok || data?.ok === false) { throw new Error(data?.error || `HTTP ${res.status}`); } return data; - }, []); + }, [rotatorEnabled]); // Render World Map const renderWorldMap = () => ( @@ -373,12 +411,12 @@ export const DockableApp = ({ showDXNews={mapLayersEff.showDXNews} // βœ… Rotator bearing overlay support - showRotatorBearing={mapLayersEff.showRotatorBearing} - rotatorAzimuth={rot.azimuth} - rotatorLastGoodAzimuth={rot.lastGoodAzimuth} - rotatorIsStale={rot.isStale} - rotatorControlEnabled={!rot.isStale} - onRotatorTurnRequest={turnRotator} + showRotatorBearing={rotatorEnabled ? mapLayersEff.showRotatorBearing : false} + rotatorAzimuth={rotatorEnabled ? rot.azimuth : null} + rotatorLastGoodAzimuth={rotatorEnabled ? rot.lastGoodAzimuth : null} + rotatorIsStale={rotatorEnabled ? rot.isStale : true} + rotatorControlEnabled={rotatorEnabled ? !rot.isStale : false} + onRotatorTurnRequest={rotatorEnabled ? turnRotator : undefined} hoveredSpot={hoveredSpot} leftSidebarVisible={true} @@ -536,7 +574,30 @@ export const DockableApp = ({ content = ; break; - case "rotator": + case "rotator": { + if (!rotatorEnabled) + return ( +
+
+ 🧭 Rotator (Local Only) +
+
+ Rotator support is disabled (or you are on the hosted site). Enable it in Settings β†’ Integrations when running OpenHamClock locally. +
+
+ ); + return ( + + ); + } + return ( { const [callsign, setCallsign] = useState(config?.callsign || ''); const [headerSize, setheaderSize] = useState(config?.headerSize || 1.0); @@ -34,6 +36,40 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave, onResetLayout, const [propMode, setPropMode] = useState(config?.propagation?.mode || 'SSB'); const [propPower, setPropPower] = useState(config?.propagation?.power || 100); const [satelliteSearch, setSatelliteSearch] = useState(''); + const isLocalInstall = useLocalInstall(); + const [rotatorEnabled, setRotatorEnabled] = useState(() => { + try { + return localStorage.getItem('ohc_rotator_enabled') === '1'; + } catch { + return false; + } + }); + + // Local-only integration flags + const [n3fjpEnabled, setN3fjpEnabled] = useState(() => { + try { + return localStorage.getItem('ohc_n3fjp_enabled') === '1'; + } catch { + return false; + } + }); + + // N3FJP UI settings (persisted) + const [n3fjpDisplayMinutes, setN3fjpDisplayMinutes] = useState(() => { + try { + const v = parseInt(localStorage.getItem('n3fjp_display_minutes') || '15', 10); + return Number.isFinite(v) ? v : 15; + } catch { + return 15; + } + }); + const [n3fjpLineColor, setN3fjpLineColor] = useState(() => { + try { + return localStorage.getItem('n3fjp_line_color') || '#3388ff'; + } catch { + return '#3388ff'; + } + }); const { t, i18n } = useTranslation(); // Layer controls @@ -83,6 +119,31 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave, onResetLayout, } }, [config, isOpen]); + // Keep rotator toggle in sync with localStorage when opening settings + useEffect(() => { + if (!isOpen) return; + try { + setRotatorEnabled(localStorage.getItem('ohc_rotator_enabled') === '1'); + } catch { + setRotatorEnabled(false); + } + }, [isOpen]); + + // Keep N3FJP toggle/settings in sync with localStorage when opening settings + useEffect(() => { + if (!isOpen) return; + try { + setN3fjpEnabled(localStorage.getItem('ohc_n3fjp_enabled') === '1'); + const v = parseInt(localStorage.getItem('n3fjp_display_minutes') || '15', 10); + setN3fjpDisplayMinutes(Number.isFinite(v) ? v : 15); + setN3fjpLineColor(localStorage.getItem('n3fjp_line_color') || '#3388ff'); + } catch { + setN3fjpEnabled(false); + setN3fjpDisplayMinutes(15); + setN3fjpLineColor('#3388ff'); + } + }, [isOpen]); + // Load layers when panel opens useEffect(() => { if (isOpen && window.hamclockLayerControls) { @@ -357,6 +418,25 @@ const handleUpdateLayerConfig = (layerId, configDelta) => { > {t('station.settings.tab1.title')} + + +