mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-02-04 09:14:30 +00:00
Use SSE frontend #3
This commit is contained in:
@@ -55,12 +55,14 @@ More will be added soon to allow customisation of filters and other display prop
|
|||||||
|
|
||||||
One of the key strengths of Spothole is that the API is well-defined and open to anyone to use. This means you can build your own software that uses data from Spothole.
|
One of the key strengths of Spothole is that the API is well-defined and open to anyone to use. This means you can build your own software that uses data from Spothole.
|
||||||
|
|
||||||
|
As well as the main API endpoints to fetch spots and alerts, with various possible query parameters, there are also Server-Sent Events (SSE) API endpoints to receive a live feed, plus various utility lookup endpoints for things like callsign and park data.
|
||||||
|
|
||||||
Various approaches exist to writing your own client, but in general:
|
Various approaches exist to writing your own client, but in general:
|
||||||
|
|
||||||
* Refer to the API docs. These are built on an OpenAPI definition file (`/webassets/apidocs/openapi.yml`), which you can automatically use to generate a client skeleton using various software.
|
* Refer to the API docs. These are built on an OpenAPI definition file (`/webassets/apidocs/openapi.yml`), which you can automatically use to generate a client skeleton using various software.
|
||||||
* Call the main "spots" or "alerts" API endpoints to get the data you want. Apply filters if necessary.
|
* Call the main "spots" or "alerts" API endpoints to get the data you want. Apply filters if necessary.
|
||||||
* Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that first before calling the spots/alerts APIs, to allow you to populate your filters correctly.
|
* Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that first before calling the spots/alerts APIs, to allow you to populate your filters correctly.
|
||||||
* Refer to the provided HTML/JS interface for a reference
|
* Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "map" and "bands" pages simply query the main spot API on a timer, whereas the main/spots page combines this approach with using the Server-Sent Events (SSE) endpoint to update live.
|
||||||
* Let me know if you get stuck, I'm happy to help!
|
* Let me know if you get stuck, I'm happy to help!
|
||||||
|
|
||||||
## Running your own copy
|
## Running your own copy
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ class WebServer:
|
|||||||
|
|
||||||
# Serve the SSE JSON API /spots/stream endpoint
|
# Serve the SSE JSON API /spots/stream endpoint
|
||||||
def serve_sse_spots_api(self):
|
def serve_sse_spots_api(self):
|
||||||
|
try:
|
||||||
response.content_type = 'text/event-stream'
|
response.content_type = 'text/event-stream'
|
||||||
response.cache_control = 'no-cache'
|
response.cache_control = 'no-cache'
|
||||||
yield 'retry: 1000\n\n'
|
yield 'retry: 1000\n\n'
|
||||||
@@ -122,10 +123,13 @@ class WebServer:
|
|||||||
else:
|
else:
|
||||||
spot = spot_queue.get()
|
spot = spot_queue.get()
|
||||||
yield 'data: ' + json.dumps(spot, default=serialize_everything) + '\n\n'
|
yield 'data: ' + json.dumps(spot, default=serialize_everything) + '\n\n'
|
||||||
|
except Exception as e:
|
||||||
|
logging.warn("Exception when serving SSE socket", e)
|
||||||
|
|
||||||
|
|
||||||
# Serve the SSE JSON API /alerts/stream endpoint
|
# Serve the SSE JSON API /alerts/stream endpoint
|
||||||
def serve_sse_alerts_api(self):
|
def serve_sse_alerts_api(self):
|
||||||
|
try:
|
||||||
response.content_type = 'text/event-stream'
|
response.content_type = 'text/event-stream'
|
||||||
response.cache_control = 'no-cache'
|
response.cache_control = 'no-cache'
|
||||||
yield 'retry: 1000\n\n'
|
yield 'retry: 1000\n\n'
|
||||||
@@ -138,6 +142,8 @@ class WebServer:
|
|||||||
else:
|
else:
|
||||||
alert = alert_queue.get()
|
alert = alert_queue.get()
|
||||||
yield 'data: ' + json.dumps(alert, default=serialize_everything) + '\n\n'
|
yield 'data: ' + json.dumps(alert, default=serialize_everything) + '\n\n'
|
||||||
|
except Exception as e:
|
||||||
|
logging.warn("Exception when serving SSE socket", e)
|
||||||
|
|
||||||
# Look up data for a callsign
|
# Look up data for a callsign
|
||||||
def serve_call_lookup_api(self):
|
def serve_call_lookup_api(self):
|
||||||
|
|||||||
16
spothole.py
16
spothole.py
@@ -1,14 +1,18 @@
|
|||||||
# Main script
|
# Main script
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import gevent
|
||||||
|
from gevent import monkey; monkey.patch_all()
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from diskcache import Cache
|
from diskcache import Cache
|
||||||
from gevent import monkey; monkey.patch_all()
|
|
||||||
|
|
||||||
from core.cleanup import CleanupTimer
|
from core.cleanup import CleanupTimer
|
||||||
from core.config import config, WEB_SERVER_PORT, SERVER_OWNER_CALLSIGN, MAX_SPOT_AGE
|
from core.config import config, WEB_SERVER_PORT, SERVER_OWNER_CALLSIGN
|
||||||
from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION
|
from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION
|
||||||
from core.lookup_helper import lookup_helper
|
from core.lookup_helper import lookup_helper
|
||||||
from core.status_reporter import StatusReporter
|
from core.status_reporter import StatusReporter
|
||||||
@@ -22,10 +26,13 @@ status_data = {}
|
|||||||
spot_providers = []
|
spot_providers = []
|
||||||
alert_providers = []
|
alert_providers = []
|
||||||
cleanup_timer = None
|
cleanup_timer = None
|
||||||
|
run = True
|
||||||
|
|
||||||
|
|
||||||
# Shutdown function
|
# Shutdown function
|
||||||
def shutdown(sig, frame):
|
def shutdown(sig, frame):
|
||||||
|
global run
|
||||||
|
|
||||||
logging.info("Stopping program, this may take a few seconds...")
|
logging.info("Stopping program, this may take a few seconds...")
|
||||||
for p in spot_providers:
|
for p in spot_providers:
|
||||||
if p.enabled:
|
if p.enabled:
|
||||||
@@ -37,6 +44,7 @@ def shutdown(sig, frame):
|
|||||||
lookup_helper.stop()
|
lookup_helper.stop()
|
||||||
spots.close()
|
spots.close()
|
||||||
alerts.close()
|
alerts.close()
|
||||||
|
run = False
|
||||||
|
|
||||||
|
|
||||||
# Utility method to get a spot provider based on the class specified in its config entry.
|
# Utility method to get a spot provider based on the class specified in its config entry.
|
||||||
@@ -105,3 +113,7 @@ if __name__ == '__main__':
|
|||||||
status_reporter.start()
|
status_reporter.start()
|
||||||
|
|
||||||
logging.info("Startup complete.")
|
logging.info("Startup complete.")
|
||||||
|
|
||||||
|
while run:
|
||||||
|
gevent.sleep(1)
|
||||||
|
exit(0)
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ paths:
|
|||||||
- Spots
|
- Spots
|
||||||
summary: Get spot stream
|
summary: Get spot stream
|
||||||
description: Request a Server-Sent Event stream which will return individual spots immediately when they are added to the system. Only spots that match the provided filters will be returned.
|
description: Request a Server-Sent Event stream which will return individual spots immediately when they are added to the system. Only spots that match the provided filters will be returned.
|
||||||
operationId: spots/stream
|
operationId: spots-stream
|
||||||
parameters:
|
parameters:
|
||||||
- name: source
|
- name: source
|
||||||
in: query
|
in: query
|
||||||
@@ -321,7 +321,7 @@ paths:
|
|||||||
- Alerts
|
- Alerts
|
||||||
summary: Get alert stream
|
summary: Get alert stream
|
||||||
description: Request a Server-Sent Event stream which will return individual alerts immediately when they are added to the system. Only alerts that match the provided filters will be returned.
|
description: Request a Server-Sent Event stream which will return individual alerts immediately when they are added to the system. Only alerts that match the provided filters will be returned.
|
||||||
operationId: alerts/stream
|
operationId: alerts-stream
|
||||||
parameters:
|
parameters:
|
||||||
- name: max_duration
|
- name: max_duration
|
||||||
in: query
|
in: query
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// How often to query the server?
|
||||||
|
const REFRESH_INTERVAL_SEC = 60;
|
||||||
|
|
||||||
// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we
|
// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we
|
||||||
// can't just ask the elements themselves for their dimensions.
|
// can't just ask the elements themselves for their dimensions.
|
||||||
BAND_COLUMN_HEIGHT_EM = 62;
|
BAND_COLUMN_HEIGHT_EM = 62;
|
||||||
|
|||||||
@@ -116,8 +116,10 @@ function toggleFilterButtons(filterQuery, state) {
|
|||||||
// Update the refresh timing display
|
// Update the refresh timing display
|
||||||
function updateRefreshDisplay() {
|
function updateRefreshDisplay() {
|
||||||
if (lastUpdateTime != null) {
|
if (lastUpdateTime != null) {
|
||||||
let count = REFRESH_INTERVAL_SEC;
|
|
||||||
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
|
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
|
||||||
|
let updatingString = " Connected to live spot server.";
|
||||||
|
if (typeof REFRESH_INTERVAL_SEC !== 'undefined' && REFRESH_INTERVAL_SEC != null) {
|
||||||
|
let count = REFRESH_INTERVAL_SEC;
|
||||||
updatingString = "Updating..."
|
updatingString = "Updating..."
|
||||||
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
|
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
|
||||||
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
|
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
|
||||||
@@ -129,6 +131,7 @@ function updateRefreshDisplay() {
|
|||||||
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number != "1" ? "s" : "") + ".</span>";
|
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number != "1" ? "s" : "") + ".</span>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
$("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
|
$("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// How often to query the server?
|
||||||
|
const REFRESH_INTERVAL_SEC = 60;
|
||||||
|
|
||||||
// Map layers
|
// Map layers
|
||||||
var markersLayer;
|
var markersLayer;
|
||||||
var geodesicsLayer;
|
var geodesicsLayer;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SSE event source
|
||||||
|
let evtSource;
|
||||||
|
|
||||||
// Load spots and populate the table.
|
// Load spots and populate the table.
|
||||||
function loadSpots() {
|
function loadSpots() {
|
||||||
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
|
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
|
||||||
@@ -8,9 +11,36 @@ function loadSpots() {
|
|||||||
spots = jsonData;
|
spots = jsonData;
|
||||||
// Update table
|
// Update table
|
||||||
updateTable();
|
updateTable();
|
||||||
|
// Start SSE connection to fetch updates in the background
|
||||||
|
restartSSEConnection();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 restartSSEConnection() {
|
||||||
|
if (evtSource != null) {
|
||||||
|
evtSource.close();
|
||||||
|
}
|
||||||
|
evtSource = new EventSource('/api/v1/spots/stream');
|
||||||
|
|
||||||
|
evtSource.onmessage = function(event) {
|
||||||
|
// Store last updated time
|
||||||
|
lastUpdateTime = moment.utc();
|
||||||
|
updateRefreshDisplay();
|
||||||
|
// Add spot to table
|
||||||
|
newSpot = JSON.parse(event.data);
|
||||||
|
console.log(newSpot);
|
||||||
|
spots.unshift(newSpot);
|
||||||
|
spots = spots.slice(0, -1);
|
||||||
|
updateTable();
|
||||||
|
};
|
||||||
|
|
||||||
|
evtSource.onerror = function(err) {
|
||||||
|
setTimeout(restartSSEConnection(), 1000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Build a query string for the API, based on the filters that the user has selected.
|
// Build a query string for the API, based on the filters that the user has selected.
|
||||||
function buildQueryString() {
|
function buildQueryString() {
|
||||||
var str = "?";
|
var str = "?";
|
||||||
@@ -316,9 +346,8 @@ function loadOptions() {
|
|||||||
$("#add-spot-button").show();
|
$("#add-spot-button").show();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load spots and set up the timer
|
// Load spots (this will also set up the SSE connection to update them too)
|
||||||
loadSpots();
|
loadSpots();
|
||||||
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,8 +413,6 @@ function displayIntroBox() {
|
|||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
||||||
loadOptions();
|
loadOptions();
|
||||||
// Update the refresh timing display every second
|
|
||||||
setInterval(updateRefreshDisplay, 1000);
|
|
||||||
// Display intro box
|
// Display intro box
|
||||||
displayIntroBox();
|
displayIntroBox();
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
// How often to query the server?
|
|
||||||
const REFRESH_INTERVAL_SEC = 60;
|
|
||||||
|
|
||||||
// Storage for the spot data that the server gives us.
|
// Storage for the spot data that the server gives us.
|
||||||
var spots = []
|
var spots = []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user