diff --git a/alertproviders/pota.py b/alertproviders/pota.py index fb47c95..ec56e53 100644 --- a/alertproviders/pota.py +++ b/alertproviders/pota.py @@ -33,7 +33,9 @@ class POTA(HTTPAlertProvider): end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M").replace(tzinfo=pytz.UTC).timestamp()) - # Add to our list. Don't worry about de-duping, removing old alerts etc. at this point; other code will do + # Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is + # in the past. Don't worry about de-duping, removing old alerts etc. at this point; other code will do # that for us. - new_alerts.append(alert) + if alert.end_time > datetime.now(pytz.UTC).timestamp(): + new_alerts.append(alert) return new_alerts diff --git a/core/cleanup.py b/core/cleanup.py index 89a7ba8..3c7eba2 100644 --- a/core/cleanup.py +++ b/core/cleanup.py @@ -10,8 +10,9 @@ import pytz class CleanupTimer: # Constructor - def __init__(self, spots, cleanup_interval): + def __init__(self, spots, alerts, cleanup_interval): self.spots = spots + self.alerts = alerts self.cleanup_interval = cleanup_interval self.cleanup_timer = None self.last_cleanup_time = datetime.min.replace(tzinfo=pytz.UTC) @@ -30,6 +31,7 @@ class CleanupTimer: try: # Perform cleanup self.spots.expire() + self.alerts.expire() self.status = "OK" self.last_cleanup_time = datetime.now(pytz.UTC) diff --git a/server/webserver.py b/server/webserver.py index e29df7b..cfbfbdc 100644 --- a/server/webserver.py +++ b/server/webserver.py @@ -17,10 +17,11 @@ from data.spot import Spot class WebServer: # Constructor - def __init__(self, spots, status_data, port): + def __init__(self, spots, alerts, status_data, port): self.last_page_access_time = None self.last_api_access_time = None self.spots = spots + self.alerts = alerts self.status_data = status_data self.port = port self.thread = Thread(target=self.run) @@ -175,17 +176,21 @@ class WebServer: # Get the query (and the right one, with Bottle magic. This is a MultiDict object) query = bottle.request.query - # Create a shallow copy of the alert list, ordered by alert time. We'll then filter it accordingly. + # Create a shallow copy of the alert list, ordered by start time. We'll then filter it accordingly. # We can filter by received time with "received_since", which take a UNIX timestamp in seconds UTC. # We can also filter by source, sig, and dx_continent. Each of these accepts a single # value or a comma-separated list. # We can provide a "limit" number as well. Alerts are always returned newest-first; "limit" limits to only the # most recent X alerts. - alert_ids = list(self.spots.iterkeys()) + alert_ids = list(self.alerts.iterkeys()) alerts = [] for k in alert_ids: - alerts.append(self.spots.get(k)) - alerts = sorted(alerts, key=lambda spot: spot.time, reverse=True) + # While we persist old spots in the system for a while to produce a useful list, any alert that has already + # passed its end time can be explicitly removed from the list to return. + # TODO deal with there being no end time + if self.alerts.get(k).end_time > datetime.now(pytz.UTC).timestamp(): + alerts.append(self.alerts.get(k)) + alerts = sorted(alerts, key=lambda alert: alert.start_time) for k in query.keys(): match k: case "received_since": diff --git a/spothole.py b/spothole.py index 5a85444..9ce94d4 100644 --- a/spothole.py +++ b/spothole.py @@ -82,11 +82,11 @@ if __name__ == '__main__': p.start() # Set up timer to clear spot list of old data - cleanup_timer = CleanupTimer(spots=spots, cleanup_interval=60) + cleanup_timer = CleanupTimer(spots=spots, alerts=alerts, cleanup_interval=60) cleanup_timer.start() # Set up web server - web_server = WebServer(spots=spots, status_data=status_data, port=WEB_SERVER_PORT) + web_server = WebServer(spots=spots, alerts= alerts, status_data=status_data, port=WEB_SERVER_PORT) web_server.start() # Set up status reporter diff --git a/views/webpage_alerts.tpl b/views/webpage_alerts.tpl index c3cbed4..4808e0a 100644 --- a/views/webpage_alerts.tpl +++ b/views/webpage_alerts.tpl @@ -12,7 +12,7 @@ -
+
diff --git a/webassets/js/alerts.js b/webassets/js/alerts.js new file mode 100644 index 0000000..40d40d3 --- /dev/null +++ b/webassets/js/alerts.js @@ -0,0 +1,249 @@ +// Storage for the alert data that the server gives us. +var alerts = [] +// Storage for the options that the server gives us. This will define our filters. +var options = {}; +// Last time we updated the alerts list on display. +var lastUpdateTime; + +// Load alerts and populate the table. +function loadAlerts() { + $.getJSON('/api/alerts' + buildQueryString(), function(jsonData) { + // Present loaded time + $("#timing-container").text("Data loaded at " + moment.utc().format('HH:mm') + " UTC."); + // Store data + alerts = jsonData; + // Update table + updateTable(); + }); +} + +// Build a query string for the API, based on the filters that the user has selected. +function buildQueryString() { + var str = "?"; + ["dx_continent", "source"].forEach(fn => { + if (!allFilterOptionsSelected(fn)) { + str = str + getQueryStringFor(fn) + "&"; + } + }); + str = str + "limit=" + $("#alerts-to-fetch option:selected").val(); + return str; +} + +// For a parameter, such as dx_continent, get the query string for the current filter options. +function getQueryStringFor(parameter) { + return parameter + "=" + encodeURIComponent(getSelectedFilterOptions(parameter)); +} + +// For a parameter, such as dx_continent, get the filter options that are currently selected in the UI. +function getSelectedFilterOptions(parameter) { + return $(".filter-button-" + parameter).filter(function() { + return this.checked; + }).map(function() { + return this.value; + }).get().join(","); +} + +// For a parameter, such as dx_continent, return true if all possible options are enabled. (In this case, we don't need +// to bother sending this as one of the query parameters to the API; no parameter provided implies "send everything".) +function allFilterOptionsSelected(parameter) { + var filter = $(".filter-button-" + parameter).filter(function() { + return !this.checked; + }).get(); + return filter.length == 0; +} + +// Update the alerts table +function updateTable() { + // Populate table with headers + let table = $('').append(''); + table.find('thead tr').append(``); + table.find('thead tr').append(``); + table.find('thead tr').append(``); + table.find('thead tr').append(``); + table.find('thead tr').append(``); + table.find('thead tr').append(``); + table.find('thead tr').append(``); + + if (alerts.length == 0) { + table.find('tbody').append(''); + } + + alerts.forEach(s => { + // Create row + let $tr = $(''); + + // Format UTC times for display + var start_time = moment.unix(s["start_time"]).utc(); + var start_time_formatted = start_time.format("YYYY-MM-DD HH:mm"); + var end_time = moment.unix(s["start_time"]).utc(); + var end_time_formatted = (end_time != null) ? end_time.format("YYYY-MM-DD HH:mm") : "Not specified"; + + // Format dx country + var dx_country = s["dx_country"] + if (dx_country == null) { + dx_country = "Unknown or not a country" + } + + // Format freqs & modes + var freqsModesText = ""; + if (s["freqs_modes"] != null) { + freqsModesText = escapeHtml(s["freqs_modes"]); + } + + // Format comment + var commentText = ""; + if (s["comment"] != null) { + commentText = escapeHtml(s["comment"]); + } + + // Sig or fallback to source + var sigSourceText = s["source"]; + if (s["sig"]) { + sigSourceText = s["sig"]; + } + + // Format sig_refs + var sig_refs = "" + if (s["sig_refs"]) { + sig_refs = s["sig_refs"].join(", ") + } + + // Populate the row + $tr.append(``); + $tr.append(``); + $tr.append(``); + $tr.append(``); + $tr.append(``); + $tr.append(``); + $tr.append(``); + table.find('tbody').append($tr); + + // Second row for mobile view only, containing source, ref, freqs/modes & comment + $tr2 = $(""); + if (s["qrt"] == true) { + $tr2.addClass("table-faded"); + } + $tr2.append(``); + table.find('tbody').append($tr2); + }); + + // Update DOM + $('#table-container').html(table); +} + +// Load server options. Once a successful callback is made from this, we then query alerts. +function loadOptions() { + $.getJSON('/api/options', function(jsonData) { + // Store options + options = jsonData; + + // Populate the filters panel + $("#settings-container").append(generateFilterCard("DX Continent", "dx_continent", options["continents"])); + $("#settings-container").append(generateFilterCard("Sources", "source", options["spot_sources"])); + + // Load settings from settings storage + loadSettings(); + + // Load alerts + loadAlerts(); + }); +} + +// Generate filter card +function generateFilterCard(displayName, filterQuery, options) { + let $col = $("
") + let $card = $("
"); + let $card_body = $("
"); + $card_body.append(`
${displayName}
`); + $p = $("

"); + // Create a button for each option + options.forEach(o => { + $p.append(` `); + }); + // Create All/None buttons + $p.append(`  `); + // Compile HTML elements to return + $card_body.append($p); + $card.append($card_body); + $col.append($card); + return $col; +} + +// Method called when "All" or "None" is clicked +function toggleFilterButtons(filterQuery, state) { + $(".filter-button-" + filterQuery).each(function() { + $(this).prop('checked', state); + }); + filtersUpdated(); +} + +// Method called when any filter is changed to reload the alerts and persist the filter settings. +function filtersUpdated() { + loadAlerts(); + saveSettings(); +} + +// Utility function to escape HTML characters from a string. +function escapeHtml(str) { + if (typeof str !== 'string') { + return ''; + } + + const escapeCharacter = (match) => { + switch (match) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + case '\'': return '''; + case '`': return '`'; + default: return match; + } + }; + + return str.replace(/[&<>"'`]/g, escapeCharacter); +} + +// Save settings to local storage +function saveSettings() { + // Find all storeable UI elements, store a key of "element id:property name" mapped to the value of that + // property. For a checkbox, that's the "checked" property. + $(".storeable-checkbox").each(function() { + localStorage.setItem($(this)[0].id + ":checked", $(this)[0].checked); + }); + $(".storeable-select").each(function() { + localStorage.setItem($(this)[0].id + ":value", $(this)[0].value); + }); +} + +// Load settings from local storage and set up the filter selectors +function loadSettings() { + // Find all local storage entries and push their data to the corresponding UI element + Object.keys(localStorage).forEach(function(key) { + // Split the key back into an element ID and a property + var split = key.split(":"); + $("#" + split[0]).prop(split[1], JSON.parse(localStorage.getItem(key))); + }); +} + +// Set up UI element event listeners, after the document is ready +function setUpEventListeners() { + $("#settings-button").click(function() { + $("#settings-area").toggle(); + }); + $("#close-settings-button").click(function() { + $("#settings-button").button("toggle"); + $("#settings-area").hide(); + }); + $("#alerts-to-fetch").click(function() { + filtersUpdated(); + }); +} + +// Startup +$(document).ready(function() { + // Call loadOptions(), this will then trigger loading alerts and setting up timers. + loadOptions(); + // Set up event listeners + setUpEventListeners(); +}); \ No newline at end of file diff --git a/webassets/js/spots.js b/webassets/js/spots.js index 3b9e5f0..ce70432 100644 --- a/webassets/js/spots.js +++ b/webassets/js/spots.js @@ -84,12 +84,12 @@ function updateTable() { // Format a UTC time for display var time = moment.unix(s["time"]).utc(); - var time_formatted = time.format("HH:mm") + var time_formatted = time.format("HH:mm"); // Format dx country - var dx_country = s["dx_country"] + var dx_country = s["dx_country"]; if (dx_country == null) { - dx_country = "Unknown or not a country" + dx_country = "Unknown or not a country"; } // Format the frequency @@ -102,7 +102,7 @@ function updateTable() { // Format the mode mode_string = s["mode"]; if (s["mode"] == null) { - mode_string = "???" + mode_string = "???"; } if (s["mode_source"] == "BANDPLAN") { mode_string = mode_string + "" @@ -121,9 +121,9 @@ function updateTable() { } // Format sig_refs - var sig_refs = "" + var sig_refs = ""; if (s["sig_refs"]) { - sig_refs = s["sig_refs"].join(", ") + sig_refs = s["sig_refs"].join(", "); } // Format DE flag @@ -133,9 +133,9 @@ function updateTable() { } // Format de country - var de_country = s["de_country"] + var de_country = s["de_country"]; if (de_country == null) { - de_country = "Unknown or not a country" + de_country = "Unknown or not a country"; } // CSS doesn't like classes with decimal points in, so we need to replace that in the same way as when we originally @@ -199,7 +199,7 @@ function loadStatus() { // Generate a status card function generateStatusCard(title, textLines) { - let $col = $("

") + let $col = $("
"); let $card = $("
"); let $card_body = $("
"); $card_body.append(`
${title}
`); @@ -226,12 +226,12 @@ function loadOptions() { $("#settings-container-2").append(generateFilterCard("Modes", "mode_type", options["mode_types"])); $("#settings-container-2").append(generateFilterCard("Sources", "source", options["spot_sources"])); - // Load filter settings from settings storage + // Load settings from settings storage loadSettings(); // Load spots and set up the timer loadSpots(); - setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000) + setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000); }); }
Start UTCEnd UTCDXFrequencies & ModesCommentSourceRef.
No alerts match your filters.
${start_time_formatted}${end_time_formatted}${s["dx_flag"]}${s["dx_call"]}${freqsModesText}${commentText} ${sigSourceText}${sig_refs}
${sig_refs} ${freqsModesText}
${commentText}