mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-18 15:33:02 +00:00
Merge pull request #50 from minrk/last_activity
store last_activity in the database
This commit is contained in:
@@ -8,6 +8,7 @@ import getpass
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -36,8 +37,10 @@ from . import handlers, apihandlers
|
|||||||
|
|
||||||
from . import orm
|
from . import orm
|
||||||
from ._data import DATA_FILES_PATH
|
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
|
# classes for config
|
||||||
from .auth import Authenticator, PAMAuthenticator
|
from .auth import Authenticator, PAMAuthenticator
|
||||||
from .spawner import Spawner, LocalProcessSpawner
|
from .spawner import Spawner, LocalProcessSpawner
|
||||||
@@ -111,7 +114,10 @@ class JupyterHubApp(Application):
|
|||||||
Useful for daemonizing jupyterhub.
|
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."
|
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:
|
with io.open(self.config_file, encoding='utf8', mode='w') as f:
|
||||||
f.write(config_text)
|
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):
|
def start(self):
|
||||||
"""Start the whole thing"""
|
"""Start the whole thing"""
|
||||||
if self.generate_config:
|
if self.generate_config:
|
||||||
@@ -664,6 +687,10 @@ class JupyterHubApp(Application):
|
|||||||
pc = PeriodicCallback(self.check_proxy, 1e3 * self.proxy_check_interval)
|
pc = PeriodicCallback(self.check_proxy, 1e3 * self.proxy_check_interval)
|
||||||
pc.start()
|
pc.start()
|
||||||
|
|
||||||
|
if self.last_activity_interval:
|
||||||
|
pc = PeriodicCallback(self.update_last_activity, 1e3 * self.last_activity_interval)
|
||||||
|
pc.start()
|
||||||
|
|
||||||
# start the webserver
|
# start the webserver
|
||||||
http_server = tornado.httpserver.HTTPServer(self.tornado_application)
|
http_server = tornado.httpserver.HTTPServer(self.tornado_application)
|
||||||
http_server.listen(self.hub_port)
|
http_server.listen(self.hub_port)
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
import errno
|
import errno
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
@@ -16,6 +17,7 @@ from sqlalchemy.types import TypeDecorator, VARCHAR
|
|||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
inspect,
|
inspect,
|
||||||
Column, Integer, String, ForeignKey, Unicode, Binary, Boolean,
|
Column, Integer, String, ForeignKey, Unicode, Binary, Boolean,
|
||||||
|
DateTime,
|
||||||
)
|
)
|
||||||
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
||||||
from sqlalchemy.orm import sessionmaker, relationship, backref
|
from sqlalchemy.orm import sessionmaker, relationship, backref
|
||||||
@@ -134,42 +136,46 @@ class Proxy(Base):
|
|||||||
else:
|
else:
|
||||||
return "<%s [unconfigured]>" % self.__class__.__name__
|
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
|
@gen.coroutine
|
||||||
def add_user(self, user, client=None):
|
def add_user(self, user, client=None):
|
||||||
"""Add a user's server to the proxy table."""
|
"""Add a user's server to the proxy table."""
|
||||||
self.log.info("Adding user %s to proxy %s => %s",
|
self.log.info("Adding user %s to proxy %s => %s",
|
||||||
user.name, user.server.base_url, user.server.host,
|
user.name, user.server.base_url, user.server.host,
|
||||||
)
|
)
|
||||||
client = client or AsyncHTTPClient()
|
|
||||||
|
|
||||||
req = HTTPRequest(url_path_join(
|
yield self.api_request(user.server.base_url,
|
||||||
self.api_server.url,
|
method='POST',
|
||||||
user.server.base_url,
|
body=dict(
|
||||||
),
|
|
||||||
method="POST",
|
|
||||||
headers={'Authorization': 'token {}'.format(self.auth_token)},
|
|
||||||
body=json.dumps(dict(
|
|
||||||
target=user.server.host,
|
target=user.server.host,
|
||||||
user=user.name,
|
user=user.name,
|
||||||
)),
|
),
|
||||||
|
client=client,
|
||||||
)
|
)
|
||||||
|
|
||||||
res = yield client.fetch(req)
|
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def delete_user(self, user, client=None):
|
def delete_user(self, user, client=None):
|
||||||
"""Remove a user's server to the proxy table."""
|
"""Remove a user's server to the proxy table."""
|
||||||
self.log.info("Removing user %s from proxy", user.name)
|
self.log.info("Removing user %s from proxy", user.name)
|
||||||
client = client or AsyncHTTPClient()
|
yield self.api_request(user.server.base_url,
|
||||||
req = HTTPRequest(url_path_join(
|
method='DELETE',
|
||||||
self.api_server.url,
|
client=client,
|
||||||
user.server.base_url,
|
|
||||||
),
|
|
||||||
method="DELETE",
|
|
||||||
headers={'Authorization': 'token {}'.format(self.auth_token)},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
res = yield client.fetch(req)
|
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def add_all_users(self):
|
def add_all_users(self):
|
||||||
@@ -186,6 +192,12 @@ class Proxy(Base):
|
|||||||
for f in futures:
|
for f in futures:
|
||||||
yield f
|
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):
|
class Hub(Base):
|
||||||
"""Bring it all together at the hub.
|
"""Bring it all together at the hub.
|
||||||
@@ -235,6 +247,7 @@ class User(Base):
|
|||||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||||
admin = Column(Boolean, default=False)
|
admin = Column(Boolean, default=False)
|
||||||
|
last_activity = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
api_tokens = relationship("APIToken", backref="user")
|
api_tokens = relationship("APIToken", backref="user")
|
||||||
cookie_tokens = relationship("CookieToken", backref="user")
|
cookie_tokens = relationship("CookieToken", backref="user")
|
||||||
@@ -277,6 +290,7 @@ class User(Base):
|
|||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def spawn(self, spawner_class, base_url='/', hub=None, config=None):
|
def spawn(self, spawner_class, base_url='/', hub=None, config=None):
|
||||||
|
"""Start the user's spawner"""
|
||||||
db = inspect(self).session
|
db = inspect(self).session
|
||||||
if hub is None:
|
if hub is None:
|
||||||
hub = db.query(Hub).first()
|
hub = db.query(Hub).first()
|
||||||
@@ -302,6 +316,7 @@ class User(Base):
|
|||||||
|
|
||||||
# store state
|
# store state
|
||||||
self.state = spawner.get_state()
|
self.state = spawner.get_state()
|
||||||
|
self.last_activity = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
yield self.server.wait_up()
|
yield self.server.wait_up()
|
||||||
@@ -309,6 +324,7 @@ class User(Base):
|
|||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
"""Stop the user's spawner"""
|
||||||
if self.spawner is None:
|
if self.spawner is None:
|
||||||
return
|
return
|
||||||
status = yield self.spawner.poll()
|
status = yield self.spawner.poll()
|
||||||
|
@@ -28,6 +28,10 @@ def random_port():
|
|||||||
sock.close()
|
sock.close()
|
||||||
return port
|
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):
|
def random_hex(nbytes):
|
||||||
"""Return nbytes random bytes as a unicode hex string
|
"""Return nbytes random bytes as a unicode hex string
|
||||||
|
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) Jupyter Development Team.
|
// Copyright (c) Jupyter Development Team.
|
||||||
// Distributed under the terms of the Modified BSD License.
|
// 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";
|
"use strict";
|
||||||
|
|
||||||
var base_url = window.jhdata.base_url;
|
var base_url = window.jhdata.base_url;
|
||||||
@@ -14,6 +14,12 @@ require(["jquery", "bootstrap", "jhapi"], function ($, bs, JHAPI) {
|
|||||||
return element;
|
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 () {
|
$(".stop-server").click(function () {
|
||||||
var el = $(this);
|
var el = $(this);
|
||||||
var row = get_row(el);
|
var row = get_row(el);
|
||||||
|
@@ -6,14 +6,16 @@
|
|||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="4">Users</th>
|
<th colspan="2">User</th>
|
||||||
|
<th colspan="3">Last Seen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
<tr class="user-row" data-user="{{u.name}}" data-admin="{{u.admin}}">
|
<tr class="user-row" data-user="{{u.name}}" data-admin="{{u.admin}}">
|
||||||
<td class="name-col col-sm-4">{{u.name}}</td>
|
<td class="name-col col-sm-2">{{u.name}}</td>
|
||||||
<td class="admin-col col-sm-4">{% if u.admin %}admin{% endif %}</td>
|
<td class="admin-col col-sm-2">{% if u.admin %}admin{% endif %}</td>
|
||||||
|
<td class="time-col col-sm-4">{{u.last_activity.isoformat() + 'Z'}}</td>
|
||||||
<td class="server-col col-sm-2 text-center">
|
<td class="server-col col-sm-2 text-center">
|
||||||
{% if u.server %}
|
{% if u.server %}
|
||||||
<span class="stop-server btn btn-xs btn-danger">stop server</span>
|
<span class="stop-server btn btn-xs btn-danger">stop server</span>
|
||||||
@@ -30,8 +32,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<tr class="user-row add-user-row">
|
<tr class="user-row add-user-row">
|
||||||
<td colspan="4">
|
<td colspan="5">
|
||||||
<a id="add-user" class="col-xs-12 btn btn-primary">Add User</a>
|
<a id="add-user" class="col-xs-12 btn btn-default">Add User</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
Reference in New Issue
Block a user