diff --git a/providers/sigrefdata/zlota.py b/providers/sigrefdata/zlota.py index be6461f..02ce7c2 100644 --- a/providers/sigrefdata/zlota.py +++ b/providers/sigrefdata/zlota.py @@ -26,7 +26,7 @@ class ZLOTA(FileDownloadSIGRefDataProvider): longitude = ref["longitude"] new_ref = SIGRef(sig=self.SIG, id=ref_id, name=ref["name"], - ref_type=ref["asset_type"].title, + ref_type=ref["asset_type"].title(), url="https://ontheair.nz/assets/" + ref_id.replace("/", "_"), latitude=latitude, longitude=longitude) diff --git a/static/js/alerts.js b/static/js/alerts.js index eb1c001..3ba0029 100644 --- a/static/js/alerts.js +++ b/static/js/alerts.js @@ -6,9 +6,10 @@ let lastUpdateTime; // Storage for the alert data that the server gives us. let alerts = []; -// Load alerts and populate the table. +// Load alerts and populate the table. No need for QRZ/HamQTH credential headers as these won't add any useful data +// to alerts function loadAlerts() { - $.ajax({url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) { + $.ajax({url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', success: function (jsonData) { // Store last updated time lastUpdateTime = moment.utc(); updateRefreshDisplay(); diff --git a/static/js/bands.js b/static/js/bands.js index 74373f1..c11ac22 100644 --- a/static/js/bands.js +++ b/static/js/bands.js @@ -1,6 +1,5 @@ // SSE connection for live spot updates -let evtSource; -let restartSSEOnErrorTimeoutId; +let sseAbortController = null; // Debounce timer so rapid SSE bursts only trigger one updateBands() call, as these could trigger a flash of the user // visible content let updateBandsDebounceId; @@ -17,10 +16,10 @@ BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6; // Load spots and populate the bands display. function loadSpots() { // Close any existing SSE connection before fetching fresh data - if (evtSource != null) { - evtSource.close(); + if (sseAbortController != null) { + sseAbortController.abort(); } - $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) { + $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) { // Store data spots = jsonData; // Update bands display @@ -33,30 +32,36 @@ function loadSpots() { // Start an SSE connection to receive new spots as they arrive. function startSSEConnection() { - if (evtSource != null) { - evtSource.close(); + if (sseAbortController != null) { + sseAbortController.abort(); } - evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString()); + sseAbortController = new AbortController(); - evtSource.onmessage = function (event) { - const newSpot = JSON.parse(event.data); + // No need to include QRZ/HamQTH credentials because the information wouldn't be displayed on the bands panel anyway + fetchEventSource('/api/v2/spots/stream' + buildQueryString(), { + signal: sseAbortController.signal, + openWhenHidden: true, - // Replace any existing spot for this callsign - spots = spots.filter(s => newSpot["dx_call"] !== s["dx_call"]); - spots.unshift(newSpot); + onmessage(event) { + if (!event.data) { + return; // heartbeat/keep-alive, nothing to do + } - // Debounce. Wait 500ms after the last message before re-rendering to avoid too many ugly flashes - clearTimeout(updateBandsDebounceId); - updateBandsDebounceId = setTimeout(updateBands, 5000); - }; + const newSpot = JSON.parse(event.data); - evtSource.onerror = function () { - if (evtSource != null) { - evtSource.close(); + // Replace any existing spot for this callsign + spots = spots.filter(s => newSpot["dx_call"] !== s["dx_call"]); + spots.unshift(newSpot); + + // Debounce. Wait 500ms after the last message before re-rendering to avoid too many ugly flashes + clearTimeout(updateBandsDebounceId); + updateBandsDebounceId = setTimeout(updateBands, 5000); + }, + onerror(err) { + console.error('SSE error:', err); + return 1000; } - clearTimeout(restartSSEOnErrorTimeoutId); - restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000); - }; + }); } // Remove spots from the display that are older than the selected max age. @@ -319,8 +324,8 @@ function displayUpdated() { $(document).ready(function () { // Close SSE connection cleanly when navigating away window.addEventListener('beforeunload', function () { - if (evtSource != null) { - evtSource.close(); + if (sseAbortController != null) { + sseAbortController.abort(); } }); diff --git a/static/js/map.js b/static/js/map.js index 8a25d81..9a71a98 100644 --- a/static/js/map.js +++ b/static/js/map.js @@ -9,8 +9,7 @@ const ITU_ZONES_COLOR_DARK = 'rgba(120, 120, 60, 1.0)'; const WAB_WAI_GRID_COLOR_DARK = 'rgba(60, 60, 120, 1.0)'; // SSE connection for live spot updates -let evtSource; -let restartSSEOnErrorTimeoutId; +let sseAbortController = null; // Map dx_call to a pair of marker & geodesic line, so we can de-duplicate spots as they arrive and only show // one marker per dx_call. The key here is actually dx_call + SSID if the spot gives us an SSID; this is mostly for // if APRS spots are enabled so we can ID a home and mobile station separately rather than our marker oscillating @@ -35,8 +34,8 @@ let firstLoad = true; // Load spots and populate the map. function loadSpots() { // Close any existing SSE connection before fetching fresh data - if (evtSource != null) { - evtSource.close(); + if (sseAbortController != null) { + sseAbortController.abort(); } // Including QRZ/HamQTH lookups to improve positions causes the load to be really slow, so on first load // the user would be waiting ages without data and will think it's broken. We therefore load in several @@ -45,7 +44,8 @@ function loadSpots() { // 2) (If we have credentials) reload with them, replacing what's already there, // 3) Subscribe to the SSE endpoint (with credentials if we have them) so that updates come with augmented // data if they can. - $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) { + $.ajax({ + url: '/api/v2/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) { // Store data spots = jsonData; // Update map @@ -54,14 +54,19 @@ function loadSpots() { terminator.setTime(); } // Check if we have any credentials to use - if (getCredentialQueryString() !== "") { + if (Object.keys(getCredentialHeaders()).length > 0) { // OK, we have credentials and have loaded once without them so the user has a basic map. Now reload // with the credentials and replace what's on the map, so we can improve the data. - $.getJSON('/api/v1/spots' + buildQueryString(true), function (jsonData2) { - spots = jsonData2; - updateMap(); - // Now start the ongoing SSE connection - startSSEConnection(); + $.ajax({ + url: '/api/v2/spots' + buildQueryString(), + dataType: 'json', + headers: getCredentialHeaders(), + success: function(jsonData2) { + spots = jsonData2; + updateMap(); + // Now start the ongoing SSE connection + startSSEConnection(); + } }); } else { // We had no credentials with which to augment the data anyway, so just start the SSE connection @@ -73,37 +78,43 @@ function loadSpots() { // Start the SSE connection to receive new spots as they arrive function startSSEConnection() { - if (evtSource != null) { - evtSource.close(); + if (sseAbortController != null) { + sseAbortController.abort(); } + sseAbortController = new AbortController(); + // SSE is going to fetch only a few spots at a time, so now we include QRZ/HamQTH credentials because the delay won't be significant. - evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString(true)); + fetchEventSource('/api/v2/spots/stream' + buildQueryString(), { + headers: getCredentialHeaders(), + signal: sseAbortController.signal, + openWhenHidden: true, - evtSource.onmessage = function (event) { - const newSpot = JSON.parse(event.data); - const key = spotKey(newSpot); + onmessage(event) { + if (!event.data) { + return; // heartbeat/keep-alive, nothing to do + } - // Remove existing marker/geodesic for this callsign if present - removeSpotFromMap(key); - spots = spots.filter(s => spotKey(s) !== key); + const newSpot = JSON.parse(event.data); + const key = spotKey(newSpot); - // Skip spots with no map coordinates - if (newSpot["dx_latitude"] == null || newSpot["dx_longitude"] == null) { - return; + // Remove existing marker/geodesic for this callsign if present + removeSpotFromMap(key); + spots = spots.filter(s => spotKey(s) !== key); + + // Skip spots with no map coordinates + if (newSpot["dx_latitude"] == null || newSpot["dx_longitude"] == null) { + return; + } + + // Add to data store and map + spots.unshift(newSpot); + addSpotToMap(newSpot); + }, + onerror(err) { + console.error('SSE error:', err); + return 1000; } - - // Add to data store and map - spots.unshift(newSpot); - addSpotToMap(newSpot); - }; - - evtSource.onerror = function () { - if (evtSource != null) { - evtSource.close(); - } - clearTimeout(restartSSEOnErrorTimeoutId); - restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000); - }; + }); } // Remove spots from the map that are older than the selected max age. @@ -603,8 +614,8 @@ function displayIntroBox() { $(document).ready(function () { // Close SSE connection cleanly when navigating away window.addEventListener('beforeunload', function () { - if (evtSource != null) { - evtSource.close(); + if (sseAbortController != null) { + sseAbortController.abort(); } }); diff --git a/static/js/spots.js b/static/js/spots.js index 98d6d3b..78ed1cc 100644 --- a/static/js/spots.js +++ b/static/js/spots.js @@ -1,26 +1,25 @@ // SSE event source -let evtSource; -let restartSSEOnErrorTimeoutId; +let sseAbortController = null; // Table row count, to alternate shading let rowCount = 0; // Set up a listener to close the SSE connection nicely when we navigate away from the page, to prevent console errors // and keep things nice and tidy for the server. window.addEventListener('beforeunload', function () { - if (evtSource != null) { - evtSource.close(); + if (sseAbortController != null) { + sseAbortController.abort(); } }); // Load spots and populate the table. function loadSpots() { // If we have an ongoing SSE connection, stop it so it doesn't interfere with our reload - if (evtSource != null) { - evtSource.close(); + if (sseAbortController != null) { + sseAbortController.abort(); } - // Make the new query - $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) { + // Make the new query. No credential headers on the first load to keep things quick + $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) { // Store data spots = jsonData; // Update table @@ -36,53 +35,60 @@ function loadSpots() { // Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the // fly. function startSSEConnection() { - if (evtSource != null) { - evtSource.close(); + if (sseAbortController != null) { + sseAbortController.abort(); } - evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString()); + sseAbortController = new AbortController(); - evtSource.onmessage = function (event) { - // Get the new spot - const newSpot = JSON.parse(event.data); - // Awful fudge to ensure new incoming spots at the top of the list don't have timestamps that make them look - // like they belong further down the list. If the spot is older than the latest one we already have, bump its - // time up to match it. This isn't great but since we poll spot providers every 2 minutes anyway, it shouldn't - // be too far wrong. - if (spots.length > 0) { - newSpot["time"] = Math.max(newSpot["time"], Math.max(...spots.map(s => s["time"]))) - } + // SSE is going to fetch only a few spots at a time, so now we include QRZ/HamQTH credentials because the delay won't be significant. + fetchEventSource('/api/v2/spots/stream' + buildQueryString(), { + headers: getCredentialHeaders(), + signal: sseAbortController.signal, + openWhenHidden: true, - // Add spot to internal data store - spots.unshift(newSpot); - // Work out if we need to remove an old spot - if (spots.length > $("#spots_to_fetch option:selected").val()) { - spots = spots.slice(0, -1); - // Drop oldest spot off the end of the table. This is two rows because of the mobile view extra rows - $("#table tbody tr").last().remove(); - $("#table tbody tr").last().remove(); - } - // If we had zero spots before (i.e. one now), the table will have a "No spots" row that we need to remove now - // that we have one. - if (spots.length === 1) { - $("#table tbody tr").last().remove(); - } + onmessage(event) { + if (!event.data) { + return; // heartbeat/keep-alive, nothing to do + } - // Add the new spot to table - addSpotToTopOfTable(newSpot, true); + // Get the new spot + const newSpot = JSON.parse(event.data); + // Awful fudge to ensure new incoming spots at the top of the list don't have timestamps that make them look + // like they belong further down the list. If the spot is older than the latest one we already have, bump its + // time up to match it. This isn't great but since we poll spot providers every 2 minutes anyway, it shouldn't + // be too far wrong. + if (spots.length > 0) { + newSpot["time"] = Math.max(newSpot["time"], Math.max(...spots.map(s => s["time"]))) + } - // Ping if we need to - if ($("#pingOnNewSpots")[0].checked) { - new Audio("/audio/ping.mp3").play(); - } - }; + // Add spot to internal data store + spots.unshift(newSpot); + // Work out if we need to remove an old spot + if (spots.length > $("#spots_to_fetch option:selected").val()) { + spots = spots.slice(0, -1); + // Drop oldest spot off the end of the table. This is two rows because of the mobile view extra rows + $("#table tbody tr").last().remove(); + $("#table tbody tr").last().remove(); + } + // If we had zero spots before (i.e. one now), the table will have a "No spots" row that we need to remove now + // that we have one. + if (spots.length === 1) { + $("#table tbody tr").last().remove(); + } - evtSource.onerror = function () { - if (evtSource != null) { - evtSource.close(); + // Add the new spot to table + addSpotToTopOfTable(newSpot, true); + + // Ping if we need to + if ($("#pingOnNewSpots")[0].checked) { + new Audio("/audio/ping.mp3").play(); + } + }, + onerror(err) { + console.error('SSE error:', err); + return 1000; } - clearTimeout(restartSSEOnErrorTimeoutId) - restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000); - }; + }); } // Build a query string for the API, based on the filters that the user has selected. diff --git a/static/vendor/js/fetch-event-source-2.0.1/fetch.js b/static/vendor/js/fetch-event-source-2.0.1/fetch.js new file mode 100644 index 0000000..e6e8e43 --- /dev/null +++ b/static/vendor/js/fetch-event-source-2.0.1/fetch.js @@ -0,0 +1,89 @@ +var __rest = (this && this.__rest) || function (s, e) { + var t = {}; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) + t[p] = s[p]; + if (s != null && typeof Object.getOwnPropertySymbols === "function") + for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { + if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) + t[p[i]] = s[p[i]]; + } + return t; +}; +import { getBytes, getLines, getMessages } from './parse.js'; +export const EventStreamContentType = 'text/event-stream'; +const DefaultRetryInterval = 1000; +const LastEventId = 'last-event-id'; +export function fetchEventSource(input, _a) { + var { signal: inputSignal, headers: inputHeaders, onopen: inputOnOpen, onmessage, onclose, onerror, openWhenHidden, fetch: inputFetch } = _a, rest = __rest(_a, ["signal", "headers", "onopen", "onmessage", "onclose", "onerror", "openWhenHidden", "fetch"]); + return new Promise((resolve, reject) => { + const headers = Object.assign({}, inputHeaders); + if (!headers.accept) { + headers.accept = EventStreamContentType; + } + let curRequestController; + function onVisibilityChange() { + curRequestController.abort(); + if (!document.hidden) { + create(); + } + } + if (!openWhenHidden) { + document.addEventListener('visibilitychange', onVisibilityChange); + } + let retryInterval = DefaultRetryInterval; + let retryTimer = 0; + function dispose() { + document.removeEventListener('visibilitychange', onVisibilityChange); + window.clearTimeout(retryTimer); + curRequestController.abort(); + } + inputSignal === null || inputSignal === void 0 ? void 0 : inputSignal.addEventListener('abort', () => { + dispose(); + resolve(); + }); + const fetch = inputFetch !== null && inputFetch !== void 0 ? inputFetch : window.fetch; + const onopen = inputOnOpen !== null && inputOnOpen !== void 0 ? inputOnOpen : defaultOnOpen; + async function create() { + var _a; + curRequestController = new AbortController(); + try { + const response = await fetch(input, Object.assign(Object.assign({}, rest), { headers, signal: curRequestController.signal })); + await onopen(response); + await getBytes(response.body, getLines(getMessages(id => { + if (id) { + headers[LastEventId] = id; + } + else { + delete headers[LastEventId]; + } + }, retry => { + retryInterval = retry; + }, onmessage))); + onclose === null || onclose === void 0 ? void 0 : onclose(); + dispose(); + resolve(); + } + catch (err) { + if (!curRequestController.signal.aborted) { + try { + const interval = (_a = onerror === null || onerror === void 0 ? void 0 : onerror(err)) !== null && _a !== void 0 ? _a : retryInterval; + window.clearTimeout(retryTimer); + retryTimer = window.setTimeout(create, interval); + } + catch (innerErr) { + dispose(); + reject(innerErr); + } + } + } + } + create(); + }); +} +function defaultOnOpen(response) { + const contentType = response.headers.get('content-type'); + if (!(contentType === null || contentType === void 0 ? void 0 : contentType.startsWith(EventStreamContentType))) { + throw new Error(`Expected content-type to be ${EventStreamContentType}, Actual: ${contentType}`); + } +} +//# sourceMappingURL=fetch.js.map \ No newline at end of file diff --git a/static/vendor/js/fetch-event-source-2.0.1/index.js b/static/vendor/js/fetch-event-source-2.0.1/index.js new file mode 100644 index 0000000..3f3d7b9 --- /dev/null +++ b/static/vendor/js/fetch-event-source-2.0.1/index.js @@ -0,0 +1,2 @@ +export { fetchEventSource, EventStreamContentType } from './fetch.js'; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/static/vendor/js/fetch-event-source-2.0.1/parse.js b/static/vendor/js/fetch-event-source-2.0.1/parse.js new file mode 100644 index 0000000..dfceafb --- /dev/null +++ b/static/vendor/js/fetch-event-source-2.0.1/parse.js @@ -0,0 +1,110 @@ +export async function getBytes(stream, onChunk) { + const reader = stream.getReader(); + let result; + while (!(result = await reader.read()).done) { + onChunk(result.value); + } +} +export function getLines(onLine) { + let buffer; + let position; + let fieldLength; + let discardTrailingNewline = false; + return function onChunk(arr) { + if (buffer === undefined) { + buffer = arr; + position = 0; + fieldLength = -1; + } + else { + buffer = concat(buffer, arr); + } + const bufLength = buffer.length; + let lineStart = 0; + while (position < bufLength) { + if (discardTrailingNewline) { + if (buffer[position] === 10) { + lineStart = ++position; + } + discardTrailingNewline = false; + } + let lineEnd = -1; + for (; position < bufLength && lineEnd === -1; ++position) { + switch (buffer[position]) { + case 58: + if (fieldLength === -1) { + fieldLength = position - lineStart; + } + break; + case 13: + discardTrailingNewline = true; + case 10: + lineEnd = position; + break; + } + } + if (lineEnd === -1) { + break; + } + onLine(buffer.subarray(lineStart, lineEnd), fieldLength); + lineStart = position; + fieldLength = -1; + } + if (lineStart === bufLength) { + buffer = undefined; + } + else if (lineStart !== 0) { + buffer = buffer.subarray(lineStart); + position -= lineStart; + } + }; +} +export function getMessages(onId, onRetry, onMessage) { + let message = newMessage(); + const decoder = new TextDecoder(); + return function onLine(line, fieldLength) { + if (line.length === 0) { + onMessage === null || onMessage === void 0 ? void 0 : onMessage(message); + message = newMessage(); + } + else if (fieldLength > 0) { + const field = decoder.decode(line.subarray(0, fieldLength)); + const valueOffset = fieldLength + (line[fieldLength + 1] === 32 ? 2 : 1); + const value = decoder.decode(line.subarray(valueOffset)); + switch (field) { + case 'data': + message.data = message.data + ? message.data + '\n' + value + : value; + break; + case 'event': + message.event = value; + break; + case 'id': + onId(message.id = value); + break; + case 'retry': + const retry = parseInt(value, 10); + if (!isNaN(retry)) { + onRetry(message.retry = retry); + } + break; + } + } + }; +} +function concat(a, b) { + const res = new Uint8Array(a.length + b.length); + res.set(a); + res.set(b, a.length); + return res; +} +function newMessage() { + return { + data: '', + event: '', + id: '', + retry: undefined, + }; +} +//# sourceMappingURL=parse.js.map \ No newline at end of file diff --git a/templates/add_spot.html b/templates/add_spot.html index 5896820..2b5ef36 100644 --- a/templates/add_spot.html +++ b/templates/add_spot.html @@ -76,7 +76,7 @@ - + diff --git a/templates/alerts.html b/templates/alerts.html index 803ea6c..31b429c 100644 --- a/templates/alerts.html +++ b/templates/alerts.html @@ -82,7 +82,7 @@ - + diff --git a/templates/bands.html b/templates/bands.html index 5898c8e..c2cbb90 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -79,8 +79,8 @@ - - + + diff --git a/templates/base.html b/templates/base.html index e1ba519..45320bf 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,6 +1,6 @@ {% extends "skeleton.html" %} {% block head_extra %} - + @@ -10,10 +10,15 @@ - - - - + + + + + + {% end %} {% block body %}
diff --git a/templates/conditions.html b/templates/conditions.html index 4f6978c..8b8ec4b 100644 --- a/templates/conditions.html +++ b/templates/conditions.html @@ -284,7 +284,7 @@
- + diff --git a/templates/map.html b/templates/map.html index 828ab10..b2882b5 100644 --- a/templates/map.html +++ b/templates/map.html @@ -112,8 +112,8 @@ - - + + diff --git a/templates/spots.html b/templates/spots.html index 64dd950..b9721fb 100644 --- a/templates/spots.html +++ b/templates/spots.html @@ -118,8 +118,8 @@ - - + + diff --git a/templates/status.html b/templates/status.html index 9c86561..68e3147 100644 --- a/templates/status.html +++ b/templates/status.html @@ -86,7 +86,7 @@ - +