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 @@
Users | +User | +Last 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 |