diff --git a/README.md b/README.md index d2694f5..9ff6e92 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ python3.6 -m pip install -Ur requirements.txt # edit config.py as you wish cp config.example.py config.py + +# build frontend +cd priv/frontend +# check instructions on README.md ``` ## Run diff --git a/config.example.py b/config.example.py index 69e5493..8f3d34c 100644 --- a/config.example.py +++ b/config.example.py @@ -1,5 +1,7 @@ PORT = 8069 +PASSWORD = '123456' + SERVICES = { 'elixire': { 'description': "elixi.re's backend", diff --git a/elstat/adapters.py b/elstat/adapters.py index be4bc33..12ab0e0 100644 --- a/elstat/adapters.py +++ b/elstat/adapters.py @@ -3,6 +3,7 @@ import time import re PING_RGX = re.compile(r'(.+)( 0% packet loss)(.+)', re.I | re.M) +PING_LATENCY_RGX = re.compile('time\=(\d+(\.\d+)?) ms', re.M) class Adapter: @@ -20,7 +21,7 @@ class PingAdapter(Adapter): """Ping the given address and report if any packet loss happened.""" spec = { - 'db': ('timestamp', 'status') + 'db': ('timestamp', 'status', 'latency') } @classmethod @@ -34,10 +35,25 @@ class PingAdapter(Adapter): out, err = map(lambda s: s.decode('utf-8'), await process.communicate()) - alive = bool(re.search(PING_RGX, out + err)) - worker.log.info(f'{worker.name}: alive? {alive}') + out += err - return (alive,) + alive = bool(re.search(PING_RGX, out)) + latency = PING_LATENCY_RGX.search(out) + + if latency is not None: + num = latency.group(1) + try: + latency = int(num) + except ValueError: + try: + latency = max(float(num), 1) + except ValueError: + latency = 0 + else: + latency = 0 + + worker.log.info(f'{worker.name}: alive? {alive} latency? {latency}ms') + return (alive, latency) class HttpAdapter(Adapter): diff --git a/elstat/blueprints/__init__.py b/elstat/blueprints/__init__.py index 07fd0b8..4feba59 100644 --- a/elstat/blueprints/__init__.py +++ b/elstat/blueprints/__init__.py @@ -1,2 +1,3 @@ from .api import bp as api +from .incidents import bp as incidents from .streaming import bp as streaming diff --git a/elstat/blueprints/decorators.py b/elstat/blueprints/decorators.py new file mode 100644 index 0000000..24ef55f --- /dev/null +++ b/elstat/blueprints/decorators.py @@ -0,0 +1,22 @@ +import hashlib + +from .errors import ApiError + + +def auth_route(handler): + """Declare an authenticated route.""" + async def _handler(request, *args, **kwargs): + try: + pwhash = request.headers['Authorization'] + except KeyError: + raise ApiError('no password provided', 403) + + correct = request.app.cfg.PASSWORD + hashed = hashlib.sha256(correct.encode()).hexdigest() + + if pwhash != hashed: + raise ApiError('invalid password', 403) + + return await handler(request, *args, **kwargs) + + return _handler diff --git a/elstat/blueprints/errors.py b/elstat/blueprints/errors.py new file mode 100644 index 0000000..fd2a2b1 --- /dev/null +++ b/elstat/blueprints/errors.py @@ -0,0 +1,8 @@ +class ApiError(Exception): + @property + def message(self): + return self.args[0] + + @property + def status_code(self): + return self.args[1] diff --git a/elstat/blueprints/incidents.py b/elstat/blueprints/incidents.py new file mode 100644 index 0000000..55fdc68 --- /dev/null +++ b/elstat/blueprints/incidents.py @@ -0,0 +1,73 @@ +import datetime + +from sanic import Blueprint, response + +from .decorators import auth_route + +bp = Blueprint(__name__) + + +# TODO: pages +@bp.get('/api/incidents') +async def get_incidents(request): + manager = request.app.manager + cur = manager.conn.cursor() + + cur.execute(""" + SELECT id, incident_type, title, content, ongoing, + start_timestamp, end_timestamp + FROM incidents + ORDER BY id DESC + """) + + rows = cur.fetchall() + + res = [] + + for row in rows: + cur = manager.conn.cursor() + + cur.execute(""" + SELECT title, content + FROM incident_stages + WHERE parent_id = ? + ORDER BY timestamp ASC + """, (row[0],)) + + stage_rows = cur.fetchall() + def stage_obj(stage_row): + return { + 'title': stage_row[0], + 'content': stage_row[1], + } + + stages = list(map(stage_obj, stage_rows)) + start_timestamp = datetime.datetime.fromtimestamp(row[5]) + end_timestamp = datetime.datetime.fromtimestamp(row[6]) + + res.append({ + 'id': str(row[0]), + 'type': row[1], + 'title': row[2], + 'content': row[3], + 'ongoing': row[4], + 'start_timestamp': start_timestamp.isoformat(), + 'end_timestamp': end_timestamp.isoformat(), + 'stages': stages + }) + + try: + first = next(iter(res)) + except StopIteration: + first = {'ongoing': False} + + return response.json({ + 'all_good': not first['ongoing'], + 'incidents': res, + }) + + +@bp.put('/api/incidents') +@auth_route +async def create_incident(request): + return response.text('im gay') diff --git a/elstat/manager.py b/elstat/manager.py index b48ca39..3521fcf 100644 --- a/elstat/manager.py +++ b/elstat/manager.py @@ -44,6 +44,26 @@ class ServiceManager: ); """) + self.conn.executescript(""" + CREATE TABLE IF NOT EXISTS incidents ( + id bigint PRIMARY KEY, + incident_type text, + title text, + content text, + ongoing bool, + start_timestamp bigint, + end_timestamp bigint + ); + + CREATE TABLE IF NOT EXISTS incident_stages ( + parent_id bigint REFERENCES incidents (id) NOT NULL, + timestamp bigint, + title text, + content text, + PRIMARY KEY (parent_id) + ); + """) + def _check(self, columns: tuple, field: str, worker_name: str): chan_name = f'{field}:{worker_name}' diff --git a/priv/frontend/package-lock.json b/priv/frontend/package-lock.json index 4e49805..a8d349e 100644 --- a/priv/frontend/package-lock.json +++ b/priv/frontend/package-lock.json @@ -4125,11 +4125,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4142,15 +4144,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -4253,7 +4258,8 @@ }, "inherits": { "version": "2.0.3", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -4263,6 +4269,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4275,17 +4282,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.2.4", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -4302,6 +4312,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -4374,7 +4385,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -4384,6 +4396,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -4489,6 +4502,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", diff --git a/run.py b/run.py index 53e155f..e0d938d 100644 --- a/run.py +++ b/run.py @@ -7,8 +7,10 @@ from sanic_cors import CORS from sanic.exceptions import NotFound, FileNotFound import config -from elstat import manager -from elstat.blueprints import api, streaming + +from elstat.manager import ServiceManager +from elstat.blueprints import api, streaming, incidents +from elstat.blueprints.errors import ApiError logging.basicConfig(level=logging.INFO) log = logging.getLogger(__name__) @@ -18,6 +20,7 @@ app.cfg = config CORS(app, automatic_options=True) app.blueprint(api) +app.blueprint(incidents) app.blueprint(streaming) @@ -25,7 +28,7 @@ app.blueprint(streaming) async def _app_start(refapp, loop): refapp.session = aiohttp.ClientSession(loop=loop) refapp.conn = sqlite3.connect('elstat.db') - refapp.manager = manager.ServiceManager(app) + refapp.manager = ServiceManager(app) @app.listener('after_server_stop') @@ -34,6 +37,15 @@ async def _app_stop(refapp, _loop): refapp.conn.close() +@app.exception(ApiError) +async def _handle_api_err(request, exception): + return response.json({ + 'error': True, + 'code': exception.status_code, + 'message': exception.message + }, status=exception.status_code) + + @app.exception(Exception) async def _handle_exc(request, exception): log.exception('oopsie woopsie')