Restructure into a frontend and a backend
The frontend was basically static files, and there was no convenient means of testing it locally. Now, the API backend is served off of http://sky-banana-party.appspot.com/, while the frontend is a bunch of static files deployed to http://skybanana.party/. This feels cleaner and, vitally, makes it possible to test frontend-only changes far more conveniently.
This commit is contained in:
Родитель
b611e77b69
Коммит
51625c5c44
19
README.md
19
README.md
|
@ -16,16 +16,17 @@ anticipated!
|
|||
|
||||
## Technical Overview
|
||||
|
||||
The app runs in [Google App Engine](https://cloud.google.com/appengine/),
|
||||
simply because it’s a system with which the author was familiar. The live app
|
||||
backend, implemented in `app/main.py`, is actually relatively simple, and just
|
||||
provides a small API for retrieving data about LIGO events and their
|
||||
localizations.
|
||||
The frontend of the website as seen by users is simple static content that’s
|
||||
stored in `frontend/`. It’s very quick to create a WWT control, and then
|
||||
there’s some custom JavaScript to download the LIGO event data and render them
|
||||
in the WWT framework. The frontend is served off of Google Cloud Storage.
|
||||
|
||||
The website as seen by users is mostly simple static content, stored in
|
||||
`app/static/`. It’s very quick to create a WWT control, and then there’s some
|
||||
custom JavaScript to download the LIGO event data and render them in the WWT
|
||||
framework.
|
||||
The frontend makes API calls against a backend that provides programmatic
|
||||
access to a small database. The backend runs in
|
||||
[Google App Engine](https://cloud.google.com/appengine/), simply because it’s
|
||||
a system with which the author was familiar. The main implementation is in
|
||||
`backend/main.py`. It’s relatively simple, and just provides a small API for
|
||||
retrieving data about LIGO events and their localizations.
|
||||
|
||||
The idea is that when LIGO is running, the app will show events as they occur.
|
||||
But in the meantime, we have to show something. The directory `bootstrap/` has
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
# The Google App Engine app
|
||||
|
||||
This is the python app that has the server-side smarts.
|
||||
|
||||
Update the deployed app with `gcloud app deploy`. Update datastore indexes
|
||||
with `gcloud datastore indexes create index.yaml`.
|
19
app/app.yaml
19
app/app.yaml
|
@ -1,19 +0,0 @@
|
|||
runtime: python37
|
||||
|
||||
# TESTING ONLY: keep static files fresh
|
||||
default_expiration: "30s"
|
||||
|
||||
handlers:
|
||||
- url: /admin/.*
|
||||
script: auto
|
||||
login: admin
|
||||
- url: /api/.*
|
||||
script: auto
|
||||
- url: /static
|
||||
static_dir: static
|
||||
- url: /
|
||||
static_files: static/index.html
|
||||
upload: static/index.html
|
||||
- url: /favicon.ico
|
||||
static_files: static/favicon.ico
|
||||
upload: static/favicon.ico
|
|
@ -0,0 +1,9 @@
|
|||
# The Google App Engine backend
|
||||
|
||||
This is the backend Python app that has the server-side smarts. It is served
|
||||
at <http://sky-banana-party.appspot.com/>. The user-facing frontend,
|
||||
<http://skybanana.party/>, makes various API calls to this backend from
|
||||
JavaScript.
|
||||
|
||||
Update the deployed app with `gcloud app deploy`. Update datastore indexes
|
||||
with `gcloud datastore indexes create index.yaml`.
|
|
@ -0,0 +1,8 @@
|
|||
runtime: python37
|
||||
|
||||
handlers:
|
||||
- url: /admin/.*
|
||||
script: auto
|
||||
login: admin
|
||||
- url: /api/.*
|
||||
script: auto
|
|
@ -7,7 +7,7 @@ from google.cloud import datastore
|
|||
import json
|
||||
import os
|
||||
|
||||
from flask import Flask
|
||||
from flask import Flask, Response
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
|
@ -100,7 +100,12 @@ def working_set():
|
|||
)
|
||||
# TODO: filter by recency
|
||||
results = list(q.fetch())
|
||||
return json.dumps(results)
|
||||
|
||||
# We need to enable CORS since we're serving from appspot.com to skybanana.party.
|
||||
resp = Response(json.dumps(results))
|
||||
resp.headers['Content-Type'] = 'application/json'
|
||||
resp.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return resp
|
||||
|
||||
|
||||
@app.route('/api/events/<string:ident>/regions')
|
||||
|
@ -115,7 +120,12 @@ def regions(ident):
|
|||
client = datastore.Client()
|
||||
key = client.key('event', ident)
|
||||
ent = client.get(key)
|
||||
return ent['regionjson']
|
||||
|
||||
# We need to enable CORS since we're serving from appspot.com to skybanana.party.
|
||||
resp = Response(ent['regionjson'])
|
||||
resp.headers['Content-Type'] = 'application/json'
|
||||
resp.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return resp
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
|
@ -155,7 +155,7 @@ def get_events():
|
|||
|
||||
|
||||
def main():
|
||||
app_dir = bpath(os.pardir, 'app')
|
||||
backend_dir = bpath(os.pardir, 'backend')
|
||||
bootstrap_data = {}
|
||||
|
||||
for ident, flitem, geojson in get_events():
|
||||
|
@ -200,7 +200,7 @@ def main():
|
|||
|
||||
result['regions'] = regions
|
||||
|
||||
with io.open(os.path.join(app_dir, 'bootstrap_data.json'), 'wt') as f:
|
||||
with io.open(os.path.join(backend_dir, 'bootstrap_data.json'), 'wt') as f:
|
||||
json.dump(bootstrap_data, f)
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
# The app frontend
|
||||
|
||||
This is the frontend of the app, served as static files off of <http://skybanana.party>.
|
||||
|
||||
## Local testing
|
||||
|
||||
The recommendend means for local testing is to use Node.js:
|
||||
|
||||
```
|
||||
npx httpserver 8080
|
||||
```
|
||||
|
||||
This server is a bit more sophisticated about MIME types and whatnot than some
|
||||
other one-liners.
|
||||
|
||||
Note that the app hardcodes a reference to the backend URL on
|
||||
<http://sky-banana-party.appspot.com/>, so it's not super convenient to test
|
||||
integrated changes to both the frontend and backend.
|
||||
|
||||
## Deployment
|
||||
|
||||
Documentation TBD.
|
|
@ -0,0 +1,71 @@
|
|||
#! /usr/bin/env python
|
||||
# Copyright 2019 Peter Williams
|
||||
# Licensed under the MIT License
|
||||
|
||||
"""Validate the `config.json` file.
|
||||
|
||||
This script should be run locally before uploading a new version of the config
|
||||
file -- it's just a safety check to make sure that we don't accidentally
|
||||
upload something busted.
|
||||
|
||||
"""
|
||||
|
||||
# note: Python 2.x compat not tested
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import io
|
||||
import json
|
||||
import os.path
|
||||
import time
|
||||
|
||||
|
||||
_frontend_dir = os.path.dirname(__file__)
|
||||
|
||||
def frontend_path(*args):
|
||||
return os.path.join(_frontend_dir, *args)
|
||||
|
||||
|
||||
def die(fmt, *args):
|
||||
import sys
|
||||
|
||||
if len(args):
|
||||
text = fmt % args
|
||||
else:
|
||||
text = str(fmt)
|
||||
|
||||
print('error:', text, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
# Maybe the biggest test: is it valid JSON?
|
||||
with io.open(frontend_path('config.json')) as f:
|
||||
config = json.load(f)
|
||||
|
||||
ligo_mode = config.get('ligo_mode')
|
||||
if not isinstance(ligo_mode, str):
|
||||
die('ligo_mode should be a str')
|
||||
|
||||
if ligo_mode == "off":
|
||||
ligo_turn_on_unix_ms = config.get('ligo_turn_on_unix_ms')
|
||||
if not isinstance(ligo_turn_on_unix_ms, int):
|
||||
die('if ligo_mode==off, ligo_turn_on_unix_ms should be an int')
|
||||
|
||||
if ligo_turn_on_unix_ms < 1548258899000: # 2019 Jan 23 in Unix-time millisecs
|
||||
die('ligo_turn_on_unix_ms suspiciously small')
|
||||
|
||||
if ligo_turn_on_unix_ms > 3294825889900: # 2119 Jan 23 or so
|
||||
die('ligo_turn_on_unix_ms suspiciously large')
|
||||
|
||||
ligo_turn_on_html_frag = config.get('ligo_turn_on_html_frag')
|
||||
if not isinstance(ligo_turn_on_html_frag, str):
|
||||
die('if not ligo_running, ligo_turn_on_html_frag should be a str')
|
||||
else:
|
||||
die('unexpected value for ligo_mode: %r', ligo_mode)
|
||||
|
||||
# If we made it here ...
|
||||
print('config looks OK')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"ligo_mode": "off",
|
||||
"ligo_turn_on_unix_ms": 1551416400000,
|
||||
"ligo_turn_on_html_frag": "<a href=\"https://www.ligo.caltech.edu/page/observatory-status\">ER14</a>"
|
||||
}
|
До Ширина: | Высота: | Размер: 8.8 KiB После Ширина: | Высота: | Размер: 8.8 KiB |
|
@ -5,13 +5,17 @@
|
|||
<meta http-equiv="content-type" content="text/html; charset=utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Sky Banana Party!</title>
|
||||
<link rel="stylesheet" href="static/style.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto">
|
||||
<script src="https://WorldWideTelescope.github.io/pywwt/wwtsdk.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-1.8.3.min.js"></script>
|
||||
<!--[if IE]> <script src="http://html5shiv.googlecode.com/svn/trunk/html5.js"></script><![endif]-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="wwtcanvas"></div>
|
||||
<script src="static/script.js"></script>
|
||||
<div id="maininfo">
|
||||
<p id="ligostatus"></p>
|
||||
</div>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,4 +1,6 @@
|
|||
(function() {
|
||||
const backend_base = "http://sky-banana-party.appspot.com/api/";
|
||||
|
||||
function size_content() {
|
||||
const new_width = ($("html").width()) + "px";
|
||||
$("#wwtcanvas").css("width", new_width);
|
||||
|
@ -18,6 +20,8 @@
|
|||
// `wwtlib.WWTControl.scriptInterface`.
|
||||
var wwt_si = wwtlib.WWTControl.initControlParam("wwtcanvas", true); // "true" => WebGL enabled
|
||||
wwt_si.add_ready(wwt_ready);
|
||||
|
||||
apply_config();
|
||||
}
|
||||
|
||||
$(document).ready(initialize);
|
||||
|
@ -64,13 +68,14 @@
|
|||
//wwt.settings.set_solarSystemPlanets(true);
|
||||
|
||||
function setup_bananas(wwt_ctl, wwt_si) {
|
||||
const wsurl = new URL("api/events/workingset", window.location.href).href;
|
||||
const wsurl = backend_base + "events/workingset";
|
||||
|
||||
$.getJSON(wsurl, function(data) {
|
||||
var index = 0;
|
||||
|
||||
for (const event_info of data) {
|
||||
const key = event_info.ident;
|
||||
const regurl = new URL("api/events/" + key + "/regions", window.location.href).href;
|
||||
const regurl = backend_base + "events/" + key + "/regions";
|
||||
event_info.index = index;
|
||||
$.getJSON(regurl, function(regions) { setup_one_event(wwt_ctl, wwt_si, event_info, regions) });
|
||||
index++;
|
||||
|
@ -125,6 +130,37 @@
|
|||
}
|
||||
}
|
||||
|
||||
function apply_config() {
|
||||
// Note: at the moment, this is called before the WWT control is
|
||||
// necessarily set up; would be straightforward to restructure if we
|
||||
// end up needing access to it.
|
||||
|
||||
$.getJSON("config.json").done(function(data) {
|
||||
var ligo_status_html;
|
||||
var seconds_until_start;
|
||||
|
||||
if (data.ligo_mode == "off") {
|
||||
seconds_until_start = 0.001 * (data.ligo_turn_on_unix_ms - Date.now());
|
||||
|
||||
if (seconds_until_start < 1) {
|
||||
// The JSON must be stale ...
|
||||
data.ligo_mode = "on";
|
||||
// ... handle this more
|
||||
}
|
||||
}
|
||||
|
||||
if (data.ligo_mode == "off") {
|
||||
ligo_status_html = "<a href=\"https://www.ligo.caltech.edu/\">LIGO</a> is currently <b>off</b>" +
|
||||
" — " + data.ligo_turn_on_html_frag + " will begin in " + wait_time_to_text(seconds_until_start) +
|
||||
"<br>Events from earlier observing runs are shown.";
|
||||
} else {
|
||||
ligo_status_html = "<a href=\"https://www.ligo.caltech.edu/\">LIGO</a>’s status is <b>unknown</b>";
|
||||
}
|
||||
|
||||
$("#ligostatus").html(ligo_status_html);
|
||||
});
|
||||
}
|
||||
|
||||
function setup_controls(wwt_ctl, wwt_si) {
|
||||
// TODO: this code is from pywwt and was designed for use in Jupyter;
|
||||
// we might be able to do something simpler here.
|
||||
|
@ -240,4 +276,20 @@
|
|||
}
|
||||
})(true));
|
||||
}
|
||||
|
||||
function wait_time_to_text(seconds) {
|
||||
if (seconds > 5184000) { // 86400 * 30 * 2
|
||||
const months = seconds / 2592000;
|
||||
return months.toFixed(0) + " months";
|
||||
} else if (seconds > 259200) { // 86400 * 3
|
||||
const days = seconds / 86400;
|
||||
return days.toFixed(0) + " days";
|
||||
} else if (seconds > 7200) { // 2 hours
|
||||
const hours = seconds / 3600;
|
||||
return "about " + hours.toFixed(0) + " hours";
|
||||
} else {
|
||||
// With timezones, etc., I don't think we'll get more precise than this.
|
||||
return "just a few hours!";
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -3,6 +3,8 @@ html {
|
|||
margin: 0px;
|
||||
padding: 0px;
|
||||
background-color: #000000;
|
||||
font-size: 1.375em;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
|
@ -17,3 +19,18 @@ body {
|
|||
border-style: none;
|
||||
border-width: 0px;
|
||||
}
|
||||
|
||||
#maininfo {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0px 0px 0.1rem 0px;
|
||||
}
|
||||
|
||||
a, a:hover, a:visited {
|
||||
color: #FFF;
|
||||
}
|
Загрузка…
Ссылка в новой задаче