From 34cebb5dbaaca5bebf49b188e9c5df5bb6c9b1d0 Mon Sep 17 00:00:00 2001 From: MinRK Date: Mon, 22 Sep 2014 17:24:09 -0700 Subject: [PATCH 1/2] add Proxy.fetch_routes and DRY up the api requests --- jupyterhub/orm.py | 56 ++++++++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 7397e7f9..88823e20 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -3,6 +3,7 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from datetime import datetime import errno import json import socket @@ -16,6 +17,7 @@ from sqlalchemy.types import TypeDecorator, VARCHAR from sqlalchemy import ( inspect, Column, Integer, String, ForeignKey, Unicode, Binary, Boolean, + DateTime, ) from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.orm import sessionmaker, relationship, backref @@ -134,42 +136,46 @@ class Proxy(Base): else: return "<%s [unconfigured]>" % self.__class__.__name__ + def api_request(self, path, method='GET', body=None, client=None): + """Make an authenticated API request of the proxy""" + client = client or AsyncHTTPClient() + url = url_path_join(self.api_server.url, path) + + if isinstance(body, dict): + body = json.dumps(body) + self.log.debug("Fetching %s %s", method, url) + req = HTTPRequest(url, + method=method, + headers={'Authorization': 'token {}'.format(self.auth_token)}, + body=body, + ) + + return client.fetch(req) + @gen.coroutine def add_user(self, user, client=None): """Add a user's server to the proxy table.""" self.log.info("Adding user %s to proxy %s => %s", user.name, user.server.base_url, user.server.host, ) - client = client or AsyncHTTPClient() - req = HTTPRequest(url_path_join( - self.api_server.url, - user.server.base_url, - ), - method="POST", - headers={'Authorization': 'token {}'.format(self.auth_token)}, - body=json.dumps(dict( + yield self.api_request(user.server.base_url, + method='POST', + body=dict( target=user.server.host, user=user.name, - )), + ), + client=client, ) - - res = yield client.fetch(req) @gen.coroutine def delete_user(self, user, client=None): """Remove a user's server to the proxy table.""" self.log.info("Removing user %s from proxy", user.name) - client = client or AsyncHTTPClient() - req = HTTPRequest(url_path_join( - self.api_server.url, - user.server.base_url, - ), - method="DELETE", - headers={'Authorization': 'token {}'.format(self.auth_token)}, + yield self.api_request(user.server.base_url, + method='DELETE', + client=client, ) - - res = yield client.fetch(req) @gen.coroutine def add_all_users(self): @@ -186,6 +192,12 @@ class Proxy(Base): for f in futures: yield f + @gen.coroutine + def fetch_routes(self, client=None): + """Fetch the proxy's routes""" + resp = yield self.api_request('/', client=client) + raise gen.Return(json.loads(resp.body.decode('utf8', 'replace'))) + class Hub(Base): """Bring it all together at the hub. @@ -235,6 +247,7 @@ class User(Base): _server_id = Column(Integer, ForeignKey('servers.id')) server = relationship(Server, primaryjoin=_server_id == Server.id) admin = Column(Boolean, default=False) + last_activity = Column(DateTime, default=datetime.utcnow) api_tokens = relationship("APIToken", backref="user") cookie_tokens = relationship("CookieToken", backref="user") @@ -277,6 +290,7 @@ class User(Base): @gen.coroutine def spawn(self, spawner_class, base_url='/', hub=None, config=None): + """Start the user's spawner""" db = inspect(self).session if hub is None: hub = db.query(Hub).first() @@ -302,6 +316,7 @@ class User(Base): # store state self.state = spawner.get_state() + self.last_activity = datetime.utcnow() db.commit() yield self.server.wait_up() @@ -309,6 +324,7 @@ class User(Base): @gen.coroutine def stop(self): + """Stop the user's spawner""" if self.spawner is None: return status = yield self.spawner.poll() From bb9ca0e04031b7158fd6e92d21caf6adef0cdd35 Mon Sep 17 00:00:00 2001 From: MinRK Date: Mon, 22 Sep 2014 17:25:10 -0700 Subject: [PATCH 2/2] store last_activity in the database fetch it periodically (10 minutes) from the proxy and display it on the admin page --- jupyterhub/app.py | 33 +++++++++++++++++++++++++++--- jupyterhub/utils.py | 4 ++++ share/jupyter/static/js/admin.js | 8 +++++++- share/jupyter/templates/admin.html | 12 ++++++----- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 5570794c..d510169d 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -8,6 +8,7 @@ import getpass import io import logging import os +from datetime import datetime from subprocess import Popen try: @@ -36,8 +37,10 @@ from . import handlers, apihandlers from . import orm from ._data import DATA_FILES_PATH -from .utils import url_path_join, random_hex, TimeoutError - +from .utils import ( + url_path_join, random_hex, TimeoutError, + ISO8601_ms, ISO8601_s, +) # classes for config from .auth import Authenticator, PAMAuthenticator from .spawner import Spawner, LocalProcessSpawner @@ -111,7 +114,10 @@ class JupyterHubApp(Application): Useful for daemonizing jupyterhub. """ ) - proxy_check_interval = Integer(10, config=True, + last_activity_interval = Integer(600, config=True, + help="Interval (in seconds) at which to update last-activity timestamps." + ) + proxy_check_interval = Integer(30, config=True, help="Interval (in seconds) at which to check if the proxy is running." ) @@ -641,6 +647,23 @@ class JupyterHubApp(Application): with io.open(self.config_file, encoding='utf8', mode='w') as f: f.write(config_text) + @gen.coroutine + def update_last_activity(self): + """Update User.last_activity timestamps from the proxy""" + routes = yield self.proxy.fetch_routes() + for prefix, route in routes.items(): + user = orm.User.find(self.db, route.get('user')) + if user is None: + self.log.warn("Found no user for route: %s", route) + continue + try: + dt = datetime.strptime(route['last_activity'], ISO8601_ms) + except Exception: + dt = datetime.strptime(route['last_activity'], ISO8601_s) + user.last_activity = max(user.last_activity, dt) + + self.db.commit() + def start(self): """Start the whole thing""" if self.generate_config: @@ -664,6 +687,10 @@ class JupyterHubApp(Application): pc = PeriodicCallback(self.check_proxy, 1e3 * self.proxy_check_interval) pc.start() + if self.last_activity_interval: + pc = PeriodicCallback(self.update_last_activity, 1e3 * self.last_activity_interval) + pc.start() + # start the webserver http_server = tornado.httpserver.HTTPServer(self.tornado_application) http_server.listen(self.hub_port) diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 236b30bf..cd21c2a5 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -28,6 +28,10 @@ def random_port(): sock.close() return port +# ISO8601 for strptime with/without milliseconds +ISO8601_ms = '%Y-%m-%dT%H:%M:%S.%fZ' +ISO8601_s = '%Y-%m-%dT%H:%M:%SZ' + def random_hex(nbytes): """Return nbytes random bytes as a unicode hex string diff --git a/share/jupyter/static/js/admin.js b/share/jupyter/static/js/admin.js index 55973997..e76e9c79 100644 --- a/share/jupyter/static/js/admin.js +++ b/share/jupyter/static/js/admin.js @@ -1,7 +1,7 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -require(["jquery", "bootstrap", "jhapi"], function ($, bs, JHAPI) { +require(["jquery", "bootstrap", "moment", "jhapi"], function ($, bs, moment, JHAPI) { "use strict"; var base_url = window.jhdata.base_url; @@ -14,6 +14,12 @@ require(["jquery", "bootstrap", "jhapi"], function ($, bs, JHAPI) { return element; }; + $(".time-col").map(function (i, el) { + // convert ISO datestamps to nice momentjs ones + el = $(el); + el.text(moment(new Date(el.text())).fromNow()); + }); + $(".stop-server").click(function () { var el = $(this); var row = get_row(el); diff --git a/share/jupyter/templates/admin.html b/share/jupyter/templates/admin.html index 45d2ce8d..2142cb3a 100644 --- a/share/jupyter/templates/admin.html +++ b/share/jupyter/templates/admin.html @@ -6,14 +6,16 @@ - + + {% for u in users %} - - + + + {% endfor %} -
UsersUserLast Seen
{{u.name}}{% if u.admin %}admin{% endif %}{{u.name}}{% if u.admin %}admin{% endif %}{{u.last_activity.isoformat() + 'Z'}} {% if u.server %} stop server @@ -30,8 +32,8 @@
- Add User + + Add User