diff --git a/index.html b/index.html
deleted file mode 100644
index badc83b..0000000
--- a/index.html
+++ /dev/null
@@ -1,5664 +0,0 @@
-
-
-
-
-
- OpenHamClock - Amateur Radio Dashboard
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/iturhfprop-service/server.js b/iturhfprop-service/server.js
index 3b0c0c2..26cebc9 100644
--- a/iturhfprop-service/server.js
+++ b/iturhfprop-service/server.js
@@ -27,6 +27,78 @@ const ITURHFPROP_DATA = process.env.ITURHFPROP_DATA || '/opt/iturhfprop';
// Temp directory for input/output files
const TEMP_DIR = '/tmp/iturhfprop';
+// === CACHING AND CIRCUIT BREAKER ===
+const predictionCache = new Map();
+const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
+const CACHE_MAX_SIZE = 100;
+
+// Circuit breaker state
+let circuitBreakerOpen = false;
+let consecutiveFailures = 0;
+let lastFailureTime = 0;
+const FAILURE_THRESHOLD = 5;
+const CIRCUIT_RESET_TIME = 60 * 1000; // 1 minute
+
+// Rate limiting - track last log time to avoid log spam
+let lastErrorLogTime = 0;
+const ERROR_LOG_INTERVAL = 10000; // Only log errors every 10 seconds
+
+function getCacheKey(params) {
+ // Round coordinates to 1 decimal place for better cache hits
+ const key = `${params.txLat.toFixed(1)},${params.txLon.toFixed(1)}-${params.rxLat.toFixed(1)},${params.rxLon.toFixed(1)}-${params.month}-${params.hour}-${params.ssn}`;
+ return key;
+}
+
+function getFromCache(key) {
+ const cached = predictionCache.get(key);
+ if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
+ return cached.data;
+ }
+ predictionCache.delete(key);
+ return null;
+}
+
+function setCache(key, data) {
+ // Enforce max cache size
+ if (predictionCache.size >= CACHE_MAX_SIZE) {
+ const oldestKey = predictionCache.keys().next().value;
+ predictionCache.delete(oldestKey);
+ }
+ predictionCache.set(key, { data, timestamp: Date.now() });
+}
+
+function checkCircuitBreaker() {
+ if (!circuitBreakerOpen) return false;
+
+ // Check if enough time has passed to try again
+ if (Date.now() - lastFailureTime > CIRCUIT_RESET_TIME) {
+ circuitBreakerOpen = false;
+ consecutiveFailures = 0;
+ console.log('[Circuit Breaker] Reset - allowing requests');
+ return false;
+ }
+ return true;
+}
+
+function recordFailure() {
+ consecutiveFailures++;
+ lastFailureTime = Date.now();
+
+ if (consecutiveFailures >= FAILURE_THRESHOLD) {
+ circuitBreakerOpen = true;
+ // Only log this once
+ if (Date.now() - lastErrorLogTime > ERROR_LOG_INTERVAL) {
+ console.log(`[Circuit Breaker] OPEN - ${consecutiveFailures} consecutive failures, pausing for ${CIRCUIT_RESET_TIME/1000}s`);
+ lastErrorLogTime = Date.now();
+ }
+ }
+}
+
+function recordSuccess() {
+ consecutiveFailures = 0;
+ circuitBreakerOpen = false;
+}
+
// Middleware
app.use(cors());
app.use(express.json());
@@ -168,7 +240,7 @@ function parseOutputFile(outputPath) {
snr: snr,
reliability: bcr
});
- console.log(`[Parse] Freq ${freq} MHz: SNR=${snr} dB, BCR=${bcr}%`);
+ // Removed per-frequency logging to reduce spam
}
}
}
@@ -180,10 +252,17 @@ function parseOutputFile(outputPath) {
results.muf = parseFloat(mufMatch[1]);
}
- console.log(`[Parse] Found ${results.frequencies.length} frequency results`);
+ // Only log success occasionally
+ if (results.frequencies.length > 0 && Date.now() - lastErrorLogTime > ERROR_LOG_INTERVAL) {
+ console.log(`[Parse] Found ${results.frequencies.length} frequency results, MUF=${results.muf || 'N/A'}`);
+ }
return results;
} catch (err) {
- console.error('[Parse Error]', err.message);
+ // Only log errors occasionally
+ if (Date.now() - lastErrorLogTime > ERROR_LOG_INTERVAL) {
+ console.error('[Parse Error]', err.message);
+ lastErrorLogTime = Date.now();
+ }
return { error: err.message, frequencies: [] };
}
}
@@ -192,6 +271,18 @@ function parseOutputFile(outputPath) {
* Run ITURHFProp prediction
*/
async function runPrediction(params) {
+ // Check circuit breaker first
+ if (checkCircuitBreaker()) {
+ return { error: 'Circuit breaker open - service temporarily unavailable', frequencies: [] };
+ }
+
+ // Check cache
+ const cacheKey = getCacheKey(params);
+ const cached = getFromCache(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
const id = crypto.randomBytes(8).toString('hex');
const inputPath = path.join(TEMP_DIR, `input_${id}.txt`);
const outputPath = path.join(TEMP_DIR, `output_${id}.txt`);
@@ -209,14 +300,15 @@ async function runPrediction(params) {
const inputContent = generateInputFile(params);
fs.writeFileSync(inputPath, inputContent);
- console.log(`[ITURHFProp] Running prediction ${id}`);
- console.log(`[ITURHFProp] TX: ${params.txLat}, ${params.txLon} -> RX: ${params.rxLat}, ${params.rxLon}`);
- console.log(`[ITURHFProp] Input file:\n${inputContent}`);
+ // Only log occasionally to avoid spam
+ const shouldLog = Date.now() - lastErrorLogTime > ERROR_LOG_INTERVAL;
+ if (shouldLog) {
+ console.log(`[ITURHFProp] Running prediction ${id}: TX(${params.txLat.toFixed(1)},${params.txLon.toFixed(1)}) -> RX(${params.rxLat.toFixed(1)},${params.rxLon.toFixed(1)})`);
+ }
// Run ITURHFProp
const startTime = Date.now();
const cmd = `${ITURHFPROP_PATH} ${inputPath} ${outputPath}`;
- console.log(`[ITURHFProp] Command: ${cmd}`);
try {
execStdout = execSync(cmd, {
@@ -224,40 +316,39 @@ async function runPrediction(params) {
encoding: 'utf8',
env: { ...process.env, LD_LIBRARY_PATH: '/opt/iturhfprop:' + (process.env.LD_LIBRARY_PATH || '') }
});
- console.log(`[ITURHFProp] stdout: ${execStdout}`);
} catch (execError) {
execStderr = execError.stderr?.toString() || '';
execStdout = execError.stdout?.toString() || '';
- console.error('[ITURHFProp] Execution error!');
- console.error('[ITURHFProp] Exit code:', execError.status);
- console.error('[ITURHFProp] stderr:', execStderr);
- console.error('[ITURHFProp] stdout:', execStdout);
- // Don't throw - try to read output anyway
+ // Only log errors periodically to avoid spam
+ if (Date.now() - lastErrorLogTime > ERROR_LOG_INTERVAL) {
+ console.error(`[ITURHFProp] Error (exit ${execError.status}): ${execStdout.substring(0, 100)}`);
+ lastErrorLogTime = Date.now();
+ }
+
+ recordFailure();
+
+ // Return empty result but cache it to avoid repeated failures
+ const errorResult = { error: `Exit code ${execError.status}`, frequencies: [], cached: false };
+ setCache(cacheKey, errorResult);
+ return errorResult;
}
const elapsed = Date.now() - startTime;
- console.log(`[ITURHFProp] Completed in ${elapsed}ms`);
-
- // Check output file
- if (fs.existsSync(outputPath)) {
- const rawOutput = fs.readFileSync(outputPath, 'utf8');
- const stats = fs.statSync(outputPath);
- console.log(`[ITURHFProp] Output file exists, size: ${stats.size} bytes`);
- console.log(`[ITURHFProp] Raw output (first 2000 chars):\n${rawOutput.substring(0, 2000)}`);
- } else {
- console.log(`[ITURHFProp] Output file NOT FOUND at ${outputPath}`);
- // Check if there's a report file in /tmp
- const tmpFiles = fs.readdirSync('/tmp').filter(f => f.startsWith('RPT') || f.startsWith('PDD'));
- console.log(`[ITURHFProp] Report files in /tmp: ${tmpFiles.join(', ') || 'none'}`);
- }
// Parse output
const results = parseOutputFile(outputPath);
+
+ if (results.frequencies.length === 0) {
+ recordFailure();
+ const errorResult = { error: 'No frequency results', frequencies: [], elapsed };
+ setCache(cacheKey, errorResult);
+ return errorResult;
+ }
+
+ // Success!
+ recordSuccess();
results.elapsed = elapsed;
- results.execStdout = execStdout;
- results.execStderr = execStderr;
- results.inputContent = inputContent;
results.params = {
txLat: params.txLat,
txLon: params.txLon,
@@ -268,6 +359,9 @@ async function runPrediction(params) {
ssn: params.ssn
};
+ // Cache successful result
+ setCache(cacheKey, results);
+
return results;
} finally {
@@ -301,7 +395,7 @@ app.get('/api/health', (req, res) => {
const ionosDataExists = fs.existsSync(ITURHFPROP_DATA + '/Data/ionos12.bin');
res.json({
- status: binaryExists && dataSubExists && libp533Exists && ionosDataExists ? 'healthy' : 'degraded',
+ status: binaryExists && dataSubExists && libp533Exists && ionosDataExists && !circuitBreakerOpen ? 'healthy' : 'degraded',
service: 'iturhfprop',
version: '1.0.0',
engine: 'ITURHFProp (ITU-R P.533-14)',
@@ -310,6 +404,11 @@ app.get('/api/health', (req, res) => {
libp372: libp372Exists ? 'found' : 'missing',
dataDir: dataSubExists ? 'found' : 'missing',
ionosData: ionosDataExists ? 'found' : 'missing',
+ circuitBreaker: {
+ open: circuitBreakerOpen,
+ consecutiveFailures,
+ cacheSize: predictionCache.size
+ },
paths: {
binary: ITURHFPROP_PATH,
data: ITURHFPROP_DATA
diff --git a/public/index.html b/public/index.html
index 6fa2502..badc83b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1891,12 +1891,19 @@
{/* 24 hour cells */}
{Array.from({ length: 24 }, (_, hour) => {
- // For current hour, use currentBands (same hybrid data as bars view)
- // For other hours, use hourlyPredictions
- let rel;
- if (hour === currentHour) {
+ // For current hour, try to use currentBands (same hybrid data as bars view)
+ // Fall back to hourlyPredictions if currentBands doesn't have this band
+ let rel = 0;
+ if (hour === currentHour && currentBands?.length > 0) {
const currentBandData = currentBands.find(b => b.band === band);
- rel = currentBandData?.reliability || 0;
+ if (currentBandData) {
+ rel = currentBandData.reliability || 0;
+ } else {
+ // Band not in currentBands, use hourlyPredictions
+ const bandData = hourlyPredictions?.[band];
+ const hourData = bandData?.find(h => h.hour === hour);
+ rel = hourData?.reliability || 0;
+ }
} else {
const bandData = hourlyPredictions?.[band];
const hourData = bandData?.find(h => h.hour === hour);
@@ -1976,7 +1983,7 @@
K = 4 ? '#ff4444' : '#00ff88' }}>{solarData.kIndex}
- {currentBands.slice(0, 11).map((band, idx) => (
+ {(currentBands || []).slice(0, 11).map((band, idx) => (