Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,9 @@ Real-time tracking of amateur radio satellites with orbital visualization on the
- Satellite positions as colored markers on the map, updated every 5 seconds
- Orbital track lines showing each satellite's path over the next pass
- Satellite name, altitude, and coordinates in the popup
- When the satellite is visible popup shows range, range-rate, and doppler factor
- (negative range rate means the satellite is approaching, positive means it is receding (moving away))
- (doppler factor is uplink/downlink frequency multiplier to account for frequency shift due to relative motion)

**How to use it:**

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"papaparse": "^5.5.3",
"react-colorful": "^5.6.1",
"react-i18next": "^16.5.4",
"satellite.js": "^5.0.0",
"satellite.js": "^6.0.0",
"ws": "^8.14.2"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion public/index-monolithic.html
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
<script src="https://unpkg.com/@joergdietrich/leaflet.terminator@1.0.0/L.Terminator.js"></script>

<!-- Satellite.js for orbital calculations -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/satellite.js/5.0.0/satellite.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/satellite.js/6.0.0/satellite.min.js"></script>

<!-- React -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
Expand Down
4 changes: 0 additions & 4 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,6 @@ const visitorStatsService = createVisitorStatsService(ctx);
Object.assign(ctx, {
visitorStats: visitorStatsService.visitorStats,
sessionTracker: visitorStatsService.sessionTracker,
geoIPCache: visitorStatsService.geoIPCache,
geoIPQueue: visitorStatsService.geoIPQueue,
todayIPSet: visitorStatsService.todayIPSet,
allTimeIPSet: visitorStatsService.allTimeIPSet,
saveVisitorStats: visitorStatsService.saveVisitorStats,
rolloverVisitorStats: visitorStatsService.rolloverVisitorStats,
STATS_FILE: visitorStatsService.STATS_FILE,
Expand Down
108 changes: 7 additions & 101 deletions server/routes/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,6 @@ module.exports = function (app, ctx) {
API_WRITE_KEY,
visitorStats,
sessionTracker,
geoIPCache,
geoIPQueue,
todayIPSet,
allTimeIPSet,
saveVisitorStats,
STATS_FILE,
rolloverVisitorStats,
Expand Down Expand Up @@ -64,15 +60,15 @@ module.exports = function (app, ctx) {
const avg =
visitorStats.history.length > 0
? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length)
: visitorStats.uniqueIPsToday.length;
: visitorStats.uniqueVisitorsToday;

// Get last 14 days for the chart
const chartData = [...visitorStats.history].slice(-14);
// Add today if we have data
if (visitorStats.uniqueIPsToday.length > 0) {
if (visitorStats.uniqueVisitorsToday > 0) {
chartData.push({
date: visitorStats.today,
uniqueVisitors: visitorStats.uniqueIPsToday.length,
uniqueVisitors: visitorStats.uniqueVisitorsToday,
totalRequests: visitorStats.totalRequestsToday,
});
}
Expand Down Expand Up @@ -445,7 +441,7 @@ module.exports = function (app, ctx) {
</div>
<div class="stat-card">
<div class="stat-icon">👥</div>
<div class="stat-value">${visitorStats.uniqueIPsToday.length}</div>
<div class="stat-value">${visitorStats.uniqueVisitorsToday}</div>
<div class="stat-label">Visitors Today</div>
</div>
<div class="stat-card">
Expand Down Expand Up @@ -546,7 +542,6 @@ module.exports = function (app, ctx) {
<thead>
<tr>
<th>#</th>
<th>IP</th>
<th style="text-align: right">Session Duration</th>
<th style="text-align: right">Requests</th>
</tr>
Expand All @@ -557,7 +552,6 @@ module.exports = function (app, ctx) {
(s, i) => `
<tr>
<td style="color: #888">${i + 1}</td>
<td><code style="color: #00ccff">${s.ip}</code></td>
<td style="text-align: right; color: #00ff88; font-weight: 600">${s.durationFormatted}</td>
<td style="text-align: right">${s.requests}</td>
</tr>
Expand Down Expand Up @@ -616,74 +610,6 @@ module.exports = function (app, ctx) {
</div>
</div>

${(() => {
// Country statistics section
const allTimeCountries = Object.entries(visitorStats.countryStats || {}).sort((a, b) => b[1] - a[1]);
const todayCountries = Object.entries(visitorStats.countryStatsToday || {}).sort((a, b) => b[1] - a[1]);
const totalResolved = allTimeCountries.reduce((s, [, v]) => s + v, 0);

if (allTimeCountries.length === 0 && geoIPQueue.size === 0) return '';

// Country code to flag emoji
const flag = (cc) => {
try {
return String.fromCodePoint(...[...cc.toUpperCase()].map((c) => 0x1f1e5 + c.charCodeAt(0) - 64));
} catch {
return '🏳';
}
};

const maxCount = allTimeCountries[0]?.[1] || 1;

return `
<div class="api-section">
<div class="api-title">
<span>🌍 Visitor Countries</span>
<span style="color: #888; font-size: 0.75rem">${geoIPCache.size} resolved, ${geoIPQueue.size} pending</span>
</div>

${
todayCountries.length > 0
? `
<div style="margin-bottom: 16px">
<div style="color: #888; font-size: 0.75rem; margin-bottom: 6px">Today</div>
<div style="display: flex; flex-wrap: wrap; gap: 6px">
${todayCountries
.map(
([cc, count]) => `
<span style="background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 4px 8px; font-size: 0.8rem">
${flag(cc)} ${cc} <span style="color: #00ff88; font-weight: 600">${count}</span>
</span>
`,
)
.join('')}
</div>
</div>`
: ''
}

<div style="color: #888; font-size: 0.75rem; margin-bottom: 6px">All-Time (${allTimeCountries.length} countries, ${totalResolved} visitors resolved)</div>
<div style="max-height: 300px; overflow-y: auto">
${allTimeCountries
.slice(0, 40)
.map(([cc, count]) => {
const pct = Math.round((count / totalResolved) * 100);
const barWidth = Math.max(2, (count / maxCount) * 100);
return `
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 3px; font-size: 0.8rem">
<span style="width: 28px; text-align: center">${flag(cc)}</span>
<span style="width: 28px; color: #888; font-family: monospace">${cc}</span>
<div style="flex: 1; background: rgba(255,255,255,0.05); border-radius: 2px; height: 16px; overflow: hidden">
<div style="width: ${barWidth}%; height: 100%; background: linear-gradient(90deg, rgba(0,100,255,0.6), rgba(0,200,100,0.6)); border-radius: 2px"></div>
</div>
<span style="width: 60px; text-align: right; font-family: monospace; color: #ccc">${count}</span>
<span style="width: 40px; text-align: right; font-size: 0.7rem; color: #888">${pct}%</span>
</div>`;
})
.join('')}
</div>
</div>`;
})()}

<div class="api-section">
<div class="api-title">
Expand Down Expand Up @@ -834,7 +760,7 @@ module.exports = function (app, ctx) {
const avg =
visitorStats.history.length > 0
? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length)
: visitorStats.uniqueIPsToday.length;
: visitorStats.uniqueVisitorsToday;

// Get endpoint monitoring stats
const apiStats = endpointStats.getStats();
Expand All @@ -861,37 +787,17 @@ module.exports = function (app, ctx) {
visitors: {
today: {
date: visitorStats.today,
uniqueVisitors: visitorStats.uniqueIPsToday.length,
uniqueVisitors: visitorStats.uniqueVisitorsToday,
totalRequests: visitorStats.totalRequestsToday,
countries: Object.entries(visitorStats.countryStatsToday || {})
.sort((a, b) => b[1] - a[1])
.reduce((o, [k, v]) => {
o[k] = v;
return o;
}, {}),
},
allTime: {
since: visitorStats.serverFirstStarted,
uniqueVisitors: visitorStats.allTimeVisitors,
totalRequests: visitorStats.allTimeRequests,
deployments: visitorStats.deploymentCount,
countries: Object.entries(visitorStats.countryStats || {})
.sort((a, b) => b[1] - a[1])
.reduce((o, [k, v]) => {
o[k] = v;
return o;
}, {}),
},
geoIP: {
resolved: geoIPCache.size,
pending: geoIPQueue.size,
coverage:
visitorStats.allTimeVisitors > 0
? `${Math.round((geoIPCache.size / visitorStats.allTimeVisitors) * 100)}%`
: '0%',
},
dailyAverage: avg,
history: visitorStats.history.slice(-30), // Last 30 days
history: visitorStats.history.slice(-30),
},
apiTraffic: {
monitoringStarted: new Date(endpointStats.startTime).toISOString(),
Expand Down
15 changes: 13 additions & 2 deletions server/routes/propagation.js
Original file line number Diff line number Diff line change
Expand Up @@ -500,11 +500,22 @@ module.exports = function (app, ctx) {
]);
if (fluxRes.status === 'fulfilled' && fluxRes.value.ok) {
const data = await fluxRes.value.json();
if (data?.length) sfi = Math.round(data[data.length - 1].flux || 150);
// f107_cm_flux.json is not sorted chronologically — find the entry with
// the latest time_tag rather than assuming the last element is current.
if (data?.length) {
const latest = data.reduce((best, d) => (d.time_tag > (best?.time_tag ?? '') ? d : best), null);
if (latest?.flux != null) sfi = Math.round(latest.flux ?? 150);
}
}
if (kRes.status === 'fulfilled' && kRes.value.ok) {
const data = await kRes.value.json();
if (data?.length > 1) kIndex = parseInt(data[data.length - 1][1]) || 2;
// NOAA changed from array-of-arrays to array-of-objects — support both.
if (data?.length) {
const last = data[data.length - 1];
const raw = Array.isArray(last) ? last[1] : last?.Kp;
const parsed = parseFloat(raw);
if (Number.isFinite(parsed)) kIndex = parsed;
}
}
ssn = Math.max(0, Math.round((sfi - 67) / 0.97));
} catch (e) {
Expand Down
60 changes: 41 additions & 19 deletions server/routes/space-weather.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ module.exports = function (app, ctx) {
// N0NBH / HamQSL cache
let n0nbhCache = { data: null, timestamp: 0 };
const N0NBH_CACHE_TTL = 60 * 60 * 1000;
// Maximum age of stale error-fallback data. N0NBH updates every ~3 hours;
// beyond 4 hours the data is definitively out of date and we should stop
// serving it rather than silently mislead clients.
const N0NBH_MAX_STALE_TTL = 4 * 60 * 60 * 1000;

// Parse N0NBH solarxml.php XML into clean JSON
function parseN0NBHxml(xml) {
Expand Down Expand Up @@ -165,7 +169,7 @@ module.exports = function (app, ctx) {
}

// SFI current fallback: N0NBH
if (!result.sfi.current && n0nbhCache.data?.solarData?.solarFlux) {
if (result.sfi.current == null && n0nbhCache.data?.solarData?.solarFlux) {
const flux = parseInt(n0nbhCache.data.solarData.solarFlux);
if (flux > 0) result.sfi.current = flux;
}
Expand All @@ -174,37 +178,46 @@ module.exports = function (app, ctx) {
if (fluxRes.status === 'fulfilled' && fluxRes.value.ok) {
const data = await fluxRes.value.json();
if (data?.length) {
const recent = data.slice(-30);
// f107_cm_flux.json is not chronologically sorted — sort before slicing
// so history shows the correct 30 most-recent readings in order.
const sorted = [...data].sort((a, b) => (a.time_tag > b.time_tag ? 1 : -1));
const recent = sorted.slice(-30);
result.sfi.history = recent.map((d) => ({
date: d.time_tag || d.date,
value: Math.round(d.flux || d.value || 0),
}));
if (!result.sfi.current) {
result.sfi.current = result.sfi.history[result.sfi.history.length - 1]?.value || null;
if (result.sfi.current == null) {
result.sfi.current = result.sfi.history[result.sfi.history.length - 1]?.value ?? null;
}
}
}

// Kp history
// NOAA changed from array-of-arrays [[header],[time,Kp,...],...] to
// array-of-objects [{time_tag,Kp,...},...] — support both formats.
if (kIndexRes.status === 'fulfilled' && kIndexRes.value.ok) {
const data = await kIndexRes.value.json();
if (data?.length > 1) {
const recent = data.slice(1).slice(-24);
if (data?.length) {
const isObj = !Array.isArray(data[0]);
const rows = isObj ? data : data.slice(1); // old format has a header row
const recent = rows.slice(-24);
result.kp.history = recent.map((d) => ({
time: d[0],
value: parseFloat(d[1]) || 0,
time: isObj ? d.time_tag : d[0],
value: Number.isFinite(isObj ? d.Kp : parseFloat(d[1])) ? (isObj ? d.Kp : parseFloat(d[1])) : 0,
}));
result.kp.current = result.kp.history[result.kp.history.length - 1]?.value || null;
result.kp.current = result.kp.history[result.kp.history.length - 1]?.value ?? null;
}
}

// Kp forecast
// Kp forecast — same format change; forecast uses lowercase 'kp' field.
if (kForecastRes.status === 'fulfilled' && kForecastRes.value.ok) {
const data = await kForecastRes.value.json();
if (data?.length > 1) {
result.kp.forecast = data.slice(1).map((d) => ({
time: d[0],
value: parseFloat(d[1]) || 0,
if (data?.length) {
const isObj = !Array.isArray(data[0]);
const rows = isObj ? data : data.slice(1);
result.kp.forecast = rows.map((d) => ({
time: isObj ? d.time_tag : d[0],
value: Number.isFinite(isObj ? d.kp : parseFloat(d[1])) ? (isObj ? d.kp : parseFloat(d[1])) : 0,
}));
}
}
Expand All @@ -224,8 +237,8 @@ module.exports = function (app, ctx) {
date: `${d['time-tag'] || d.time_tag || ''}`,
value: Math.round(d.ssn || d['ISES SSN'] || 0),
}));
if (!result.ssn.current) {
result.ssn.current = result.ssn.history[result.ssn.history.length - 1]?.value || null;
if (result.ssn.current == null) {
result.ssn.current = result.ssn.history[result.ssn.history.length - 1]?.value ?? null;
}
}
}
Expand Down Expand Up @@ -570,10 +583,18 @@ module.exports = function (app, ctx) {
const parsed = parseN0NBHxml(xml);

n0nbhCache = { data: parsed, timestamp: Date.now() };
res.json(parsed);
res.json({ ...parsed, fetchedAt: n0nbhCache.timestamp });
} catch (error) {
logErrorOnce('N0NBH', error.message);
if (n0nbhCache.data) return res.json(n0nbhCache.data);
if (n0nbhCache.data) {
const age = Date.now() - n0nbhCache.timestamp;
if (age > N0NBH_MAX_STALE_TTL) {
// Cache is too old to be useful; tell the client so it can show a
// meaningful error rather than silently displaying stale conditions.
return res.status(503).json({ error: 'N0NBH data unavailable and cached data is too stale' });
}
return res.json({ ...n0nbhCache.data, fetchedAt: n0nbhCache.timestamp, stale: true });
}
res.status(500).json({ error: 'Failed to fetch N0NBH data' });
}
});
Expand All @@ -599,7 +620,8 @@ module.exports = function (app, ctx) {
try {
const response = await fetch('https://www.hamqsl.com/solarxml.php');
const xml = await response.text();
n0nbhCache = { data: parseN0NBHxml(xml), timestamp: Date.now() };
const ts = Date.now();
n0nbhCache = { data: { ...parseN0NBHxml(xml), fetchedAt: ts }, timestamp: ts };
logInfo('[Startup] N0NBH solar data pre-warmed');
} catch (e) {
logWarn('[Startup] N0NBH pre-warm failed:', e.message);
Expand Down
Loading