From 51625c5c44271d495b031c71de065884d8005cd9 Mon Sep 17 00:00:00 2001 From: Peter Williams Date: Wed, 23 Jan 2019 12:34:09 -0500 Subject: [PATCH] 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. --- README.md | 19 +++---- app/README.md | 6 --- app/app.yaml | 19 ------- {app => backend}/.gcloudignore | 0 {app => backend}/.gitignore | 0 backend/README.md | 9 ++++ backend/app.yaml | 8 +++ {app => backend}/index.yaml | 0 {app => backend}/main.py | 16 ++++-- {app => backend}/requirements.txt | 0 bootstrap/postprocess.py | 4 +- frontend/README.md | 22 +++++++++ frontend/check-config.py | 71 +++++++++++++++++++++++++++ frontend/config.json | 5 ++ {app/static => frontend}/favicon.ico | Bin {app/static => frontend}/index.html | 8 ++- {app/static => frontend}/script.js | 56 ++++++++++++++++++++- {app/static => frontend}/style.css | 17 +++++++ 18 files changed, 217 insertions(+), 43 deletions(-) delete mode 100644 app/README.md delete mode 100644 app/app.yaml rename {app => backend}/.gcloudignore (100%) rename {app => backend}/.gitignore (100%) create mode 100644 backend/README.md create mode 100644 backend/app.yaml rename {app => backend}/index.yaml (100%) rename {app => backend}/main.py (86%) rename {app => backend}/requirements.txt (100%) create mode 100644 frontend/README.md create mode 100755 frontend/check-config.py create mode 100644 frontend/config.json rename {app/static => frontend}/favicon.ico (100%) rename {app/static => frontend}/index.html (74%) rename {app/static => frontend}/script.js (80%) rename {app/static => frontend}/style.css (52%) diff --git a/README.md b/README.md index ebc0dbc..3f74f3f 100644 --- a/README.md +++ b/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 diff --git a/app/README.md b/app/README.md deleted file mode 100644 index 3c956e4..0000000 --- a/app/README.md +++ /dev/null @@ -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`. diff --git a/app/app.yaml b/app/app.yaml deleted file mode 100644 index ddb5a1b..0000000 --- a/app/app.yaml +++ /dev/null @@ -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 diff --git a/app/.gcloudignore b/backend/.gcloudignore similarity index 100% rename from app/.gcloudignore rename to backend/.gcloudignore diff --git a/app/.gitignore b/backend/.gitignore similarity index 100% rename from app/.gitignore rename to backend/.gitignore diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..9b58276 --- /dev/null +++ b/backend/README.md @@ -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 . The user-facing frontend, +, 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`. diff --git a/backend/app.yaml b/backend/app.yaml new file mode 100644 index 0000000..f918085 --- /dev/null +++ b/backend/app.yaml @@ -0,0 +1,8 @@ +runtime: python37 + +handlers: +- url: /admin/.* + script: auto + login: admin +- url: /api/.* + script: auto diff --git a/app/index.yaml b/backend/index.yaml similarity index 100% rename from app/index.yaml rename to backend/index.yaml diff --git a/app/main.py b/backend/main.py similarity index 86% rename from app/main.py rename to backend/main.py index e9f2a1d..79c743f 100644 --- a/app/main.py +++ b/backend/main.py @@ -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//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__': diff --git a/app/requirements.txt b/backend/requirements.txt similarity index 100% rename from app/requirements.txt rename to backend/requirements.txt diff --git a/bootstrap/postprocess.py b/bootstrap/postprocess.py index e7cb600..8a86cb8 100755 --- a/bootstrap/postprocess.py +++ b/bootstrap/postprocess.py @@ -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) diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d5d163f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,22 @@ +# The app frontend + +This is the frontend of the app, served as static files off of . + +## 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 +, so it's not super convenient to test +integrated changes to both the frontend and backend. + +## Deployment + +Documentation TBD. diff --git a/frontend/check-config.py b/frontend/check-config.py new file mode 100755 index 0000000..61d226b --- /dev/null +++ b/frontend/check-config.py @@ -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() diff --git a/frontend/config.json b/frontend/config.json new file mode 100644 index 0000000..f09ee6b --- /dev/null +++ b/frontend/config.json @@ -0,0 +1,5 @@ +{ + "ligo_mode": "off", + "ligo_turn_on_unix_ms": 1551416400000, + "ligo_turn_on_html_frag": "ER14" +} diff --git a/app/static/favicon.ico b/frontend/favicon.ico similarity index 100% rename from app/static/favicon.ico rename to frontend/favicon.ico diff --git a/app/static/index.html b/frontend/index.html similarity index 74% rename from app/static/index.html rename to frontend/index.html index e8e7a42..d4592e2 100644 --- a/app/static/index.html +++ b/frontend/index.html @@ -5,13 +5,17 @@ Sky Banana Party! - + +
- +
+

+
+ diff --git a/app/static/script.js b/frontend/script.js similarity index 80% rename from app/static/script.js rename to frontend/script.js index 14ce0ea..d9fec7e 100644 --- a/app/static/script.js +++ b/frontend/script.js @@ -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 = "LIGO is currently off" + + " — " + data.ligo_turn_on_html_frag + " will begin in " + wait_time_to_text(seconds_until_start) + + "
Events from earlier observing runs are shown."; + } else { + ligo_status_html = "LIGO’s status is unknown"; + } + + $("#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!"; + } + } })(); diff --git a/app/static/style.css b/frontend/style.css similarity index 52% rename from app/static/style.css rename to frontend/style.css index efd42f0..695b485 100644 --- a/app/static/style.css +++ b/frontend/style.css @@ -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; +}