Compare commits

...

36 Commits
0.9.0 ... 0.9.1

Author SHA1 Message Date
Min RK
22863f765f 0.9.1 2018-07-04 11:55:42 +02:00
Min RK
b500bd002b Merge pull request #2014 from willingc/bump-testing
add python 3.7 to travis
2018-07-04 11:02:55 +02:00
Carol Willing
aca40b24c3 remove env 2018-07-03 16:32:05 -07:00
Carol Willing
b5fe5a80c6 remove 3.7 from python list but leave in matrix 2018-07-03 14:57:58 -07:00
Carol Willing
ad073dd5dd add 3.7 to travis matrix 2018-07-03 14:44:09 -07:00
Carol Willing
7b815558c6 Merge pull request #2021 from minrk/091-changes
Prepare changelog for 0.9.1
2018-07-03 14:27:01 -07:00
Min RK
55f58b3ba7 review, note proxy prefix fix 2018-07-03 15:12:30 +02:00
Min RK
e1f93a4721 Merge pull request #2009 from BerserkerTroll/patch-2
proxy.py: Respect base_url in add_hub_route
2018-07-03 13:36:48 +02:00
Min RK
2e95f3c039 Merge branch 'master' into patch-2 2018-07-03 13:29:54 +02:00
Min RK
b0ba51f209 host-based routing doesn't support wildcards 2018-07-03 12:27:24 +02:00
Min RK
89e6c2110e add hub.routespec
this is the routespec for sending requests to the hub

It is [host]/prefix/ (not /hub/) so it receives all
requests, not just those destined for the hub
2018-07-03 12:05:21 +02:00
Min RK
7dfdc23b4e Prepare changelog for 0.9.1 2018-07-03 11:44:37 +02:00
Min RK
4c7df53a8a Merge pull request #2020 from weatherforce/master
Fix a couple of typos in the technical reference documentation
2018-07-03 11:23:36 +02:00
Alex Marandon
678afd3783 Fix a couple of typos 2018-07-03 11:16:55 +02:00
Carol Willing
0185a08f32 Merge pull request #2015 from minrk/allow_remote
disable host checking in upcoming notebook app
2018-07-02 08:45:41 -07:00
Tim Head
f3787dd2c8 Merge pull request #2016 from minrk/spawner-docs
mention get_env and get_args in spawner reference
2018-06-30 09:59:38 +02:00
Min RK
30f19cfc8c mention get_env and get_args in spawner reference
these are important and usually required (especially get_env) for custom Spawner implementations
2018-06-29 14:46:08 +02:00
Min RK
a84fa38c6b ensure prefix is on next_url in test_pages 2018-06-29 14:21:32 +02:00
Min RK
867ce4c213 use app.base_url in Proxy.check_routes
rather than assuming '/'
2018-06-29 14:19:20 +02:00
Min RK
005118e09d disable upcoming host checking in single-user notebook application 2018-06-29 11:55:47 +02:00
Carol Willing
04ce67ee71 add python 3.7 to travis 2018-06-28 08:47:04 -07:00
Min RK
31807929cb update test expectations for proxy state
expect app.base_url instead of unconditional ‘/‘
2018-06-27 12:46:13 +02:00
Min RK
cb4105b53e Merge pull request #2012 from josemonsalve2/master
c.LocalProcessSpawner.shell_cmd configuration option does not work
2018-06-27 12:39:19 +02:00
Carol Willing
151887dd56 Merge pull request #2008 from minrk/services-localhost
managed services always talk to hub on localhost
2018-06-26 12:07:29 -07:00
Carol Willing
5f97487184 Merge pull request #2001 from minrk/auto-spawn-api
avoid triggering a spawn from API requests to a not-running server
2018-06-26 12:04:10 -07:00
Carol Willing
4d2d677777 Merge pull request #1996 from minrk/proxy-cleanup
use pid file to check for previous proxy instances
2018-06-26 12:02:54 -07:00
Jose M Monsalve Diaz
6a3b3807c9 fixing shell_cmd attribute of the LocalProcessSpawner that was not tag for configuration 2018-06-25 17:07:39 -04:00
Min RK
02a52a0289 Merge pull request #1997 from gesiscss/master
fix wrong/missing closing tags in templates
2018-06-25 12:54:36 +02:00
BerserkerTroll
7bd1e387df proxy.py: Respect base_url in add_hub_route 2018-06-24 17:22:43 +03:00
Min RK
edc0d7901f services always talk to hub on localhost
When the Hub listens on all ips by default, the connection ip is the hostname.

in some cases (e.g. certain kubernetes deployments) the hub’s container’s hostname is not connectable from itself, preventing managed services from connecting to the hub.

This ensures that managed service processes talk to the hub over localhost in this case, rather than via the hostname.
2018-06-22 13:48:34 +02:00
Min RK
8e561f1c12 avoid triggering a spawn from API requests to a not-running server
this avoids left-open notebook tabs from respawning a culled server indefinitely
2018-06-20 14:57:41 +02:00
Kenan Erdogan
24d87c882f fix wrong/missing closing tags in templates 2018-06-19 09:15:18 +02:00
Min RK
1e333e2f29 Merge pull request #1992 from willingc/doc-toc
add templates and user env docs to home page index
2018-06-18 15:45:52 +02:00
Carol Willing
a507fa1c8a add templates and user env docs to home page index 2018-06-16 10:21:33 -07:00
Min RK
90cc03b3ec back to dev 2018-06-15 15:39:02 +02:00
Min RK
ec83708892 use pid file to check for previous proxy instances
avoids failure to start when the previous proxy wasn't cleaned up properly
2018-06-14 17:50:33 +02:00
21 changed files with 214 additions and 60 deletions

View File

@@ -62,5 +62,7 @@ matrix:
- python: 3.6
env:
- JUPYTERHUB_TEST_DB_URL=postgresql://postgres@127.0.0.1/jupyterhub
- python: 3.7
dist: xenial
allow_failures:
- python: nightly

View File

@@ -9,6 +9,19 @@ command line for details.
## 0.9
### [0.9.1] 2018-07-04
JupyterHub 0.9.1 contains a number of small bugfixes on top of 0.9.
- Use a PID file for the proxy to decrease the likelihood that a leftover proxy process will prevent JupyterHub from restarting
- `c.LocalProcessSpawner.shell_cmd` is now configurable
- API requests to stopped servers (requests to the hub for `/user/:name/api/...`) fail with 404 rather than triggering a restart of the server
- Compatibility fix for notebook 5.6.0 which will introduce further
security checks for local connections
- Managed services always use localhost to talk to the Hub if the Hub listening on all interfaces
- When using a URL prefix, the Hub route will be `JupyterHub.base_url` instead of unconditionally `/`
- additional fixes and improvements
### [0.9.0] 2018-06-15
JupyterHub 0.9 is a major upgrade of JupyterHub.
@@ -379,7 +392,8 @@ Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
First preview release
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/0.9.0...HEAD
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/0.9.1...HEAD
[0.9.1]: https://github.com/jupyterhub/jupyterhub/compare/0.9.0...0.9.1
[0.9.0]: https://github.com/jupyterhub/jupyterhub/compare/0.8.1...0.9.0
[0.8.1]: https://github.com/jupyterhub/jupyterhub/compare/0.8.0...0.8.1
[0.8.0]: https://github.com/jupyterhub/jupyterhub/compare/0.7.2...0.8.0

View File

@@ -58,6 +58,8 @@ Contents
* :doc:`reference/services`
* :doc:`reference/rest`
* :doc:`reference/upgrading`
* :doc:`reference/templates`
* :doc:`reference/config-user-env`
* :doc:`reference/config-examples`
* :doc:`reference/config-ghoauth`
* :doc:`reference/config-proxy`

View File

@@ -2,7 +2,7 @@
JupyterHub 0.8 introduced the ability to write a custom implementation of the
proxy. This enables deployments with different needs than the default proxy,
configurable-http-proxy (CHP). CHP is a single-process nodejs proxy that they
configurable-http-proxy (CHP). CHP is a single-process nodejs proxy that the
Hub manages by default as a subprocess (it can be run externally, as well, and
typically is in production deployments).

View File

@@ -15,7 +15,7 @@ This section provides the following information about Services:
## Definition of a Service
When working with JupyterHub, a **Service** is defined as a process that interacts
with the Hub's REST API. A Service may perform a specific or
with the Hub's REST API. A Service may perform a specific
action or task. For example, the following tasks can each be a unique Service:
- shutting down individuals' single user notebook servers that have been idle

View File

@@ -46,7 +46,16 @@ Most `Spawner.start` functions will look similar to this example:
def start(self):
self.ip = '127.0.0.1'
self.port = random_port()
yield self._actually_start_server_somehow()
# get environment variables,
# several of which are required for configuring the single-user server
env = self.get_env()
cmd = []
# get jupyterhub command to run,
# typically ['jupyterhub-singleuser']
cmd.extend(self.cmd)
cmd.extend(self.get_args())
yield self._actually_start_server_somehow(cmd, env)
return (self.ip, self.port)
```

View File

@@ -6,9 +6,9 @@
version_info = (
0,
9,
0,
"", # release (b1, rc1)
# "dev", # dev
1,
"", # release (b1, rc1, or "" for final)
# "dev", # dev or nothing
)
# pep 440 version: no dot before beta/rc, but before .dev

View File

@@ -1108,7 +1108,18 @@ class JupyterHub(Application):
else:
hub_args['ip'] = self.hub_ip
hub_args['port'] = self.hub_port
self.hub = Hub(**hub_args)
# routespec for the Hub is the *app* base url
# not the hub URL, so it receives requests for non-running servers
# use `/` with host-based routing so the Hub
# gets requests for all hosts
host = ''
if self.subdomain_host:
routespec = '/'
else:
routespec = self.base_url
self.hub = Hub(routespec=routespec, **hub_args)
if self.hub_connect_ip:
self.hub.connect_ip = self.hub_connect_ip

View File

@@ -6,6 +6,7 @@
import copy
from datetime import datetime, timedelta
from http.client import responses
import json
import math
import random
import re
@@ -884,6 +885,13 @@ class UserSpawnHandler(BaseHandler):
which will in turn send her to /user/alice/notebooks/mynotebook.ipynb.
"""
def _fail_api_request(self, user):
"""Fail an API request to a not-running server"""
self.set_status(404)
self.set_header("Content-Type", "application/json")
self.write(json.dumps({"message": "%s is not running" % user.name}))
self.finish()
async def get(self, name, user_path):
if not user_path:
user_path = '/'
@@ -910,6 +918,11 @@ class UserSpawnHandler(BaseHandler):
# otherwise redirect users to their own server
should_spawn = (current_user and current_user.name == name)
if "api" in user_path.split("/") and not user.active:
# API request for not-running server (e.g. notebook UI left open)
# Avoid triggering a spawn.
self._fail_api_request(user)
return
if should_spawn:
# if spawning fails for any reason, point users to /hub/home to retry

View File

@@ -63,6 +63,9 @@ class Server(HasTraits):
@validate('connect_url')
def _connect_url_add_prefix(self, proposal):
"""Ensure connect_url includes base_url"""
if not proposal.value:
# Don't add the prefix if the setting is being cleared
return proposal.value
urlinfo = urlparse(proposal.value)
if not urlinfo.path.startswith(self.base_url):
urlinfo = urlinfo._replace(path=self.base_url)
@@ -185,6 +188,7 @@ class Hub(Server):
)
return self
public_host = Unicode()
routespec = Unicode()
@property
def api_url(self):

View File

@@ -22,8 +22,10 @@ import asyncio
from functools import wraps
import json
import os
import signal
from subprocess import Popen
from urllib.parse import quote
import time
from urllib.parse import quote, urlparse
from tornado import gen
from tornado.httpclient import AsyncHTTPClient, HTTPRequest, HTTPError
@@ -301,13 +303,13 @@ class Proxy(LoggingConfigurable):
user_routes = {path for path, r in routes.items() if 'user' in r['data']}
futures = []
good_routes = {'/'}
good_routes = {self.app.hub.routespec}
hub = self.app.hub
if '/' not in routes:
hub = self.hub
if self.app.hub.routespec not in routes:
futures.append(self.add_hub_route(hub))
else:
route = routes['/']
route = routes[self.app.hub.routespec]
if route['target'] != hub.host:
self.log.warning("Updating default route %s%s", route['target'], hub.host)
futures.append(self.add_hub_route(hub))
@@ -365,8 +367,8 @@ class Proxy(LoggingConfigurable):
def add_hub_route(self, hub):
"""Add the default route for the Hub"""
self.log.info("Adding default route for Hub: / => %s", hub.host)
return self.add_route('/', self.hub.host, {'hub': True})
self.log.info("Adding default route for Hub: %s => %s", hub.routespec, hub.host)
return self.add_route(hub.routespec, self.hub.host, {'hub': True})
async def restore_routes(self):
self.log.info("Setting up routes on new proxy")
@@ -437,6 +439,12 @@ class ConfigurableHTTPProxy(Proxy):
help="""The command to start the proxy"""
)
pid_file = Unicode(
"jupyterhub-proxy.pid",
config=True,
help="File in which to write the PID of the proxy process.",
)
_check_running_callback = Any(help="PeriodicCallback to check if the proxy is running")
def __init__(self, **kwargs):
@@ -448,9 +456,72 @@ class ConfigurableHTTPProxy(Proxy):
" if Proxy.should_start is False" % self.__class__.__name__
)
def _check_previous_process(self):
"""Check if there's a process leftover and shut it down if so"""
if not self.pid_file or not os.path.exists(self.pid_file):
return
pid_file = os.path.abspath(self.pid_file)
self.log.warning("Found proxy pid file: %s", pid_file)
try:
with open(pid_file, "r") as f:
pid = int(f.read().strip())
except ValueError:
self.log.warning("%s did not appear to contain a pid", pid_file)
self._remove_pid_file()
return
try:
os.kill(pid, 0)
except ProcessLookupError:
self.log.warning("Proxy no longer running at pid=%s", pid)
self._remove_pid_file()
return
# if we got here, CHP is still running
self.log.warning("Proxy still running at pid=%s", pid)
for i, sig in enumerate([signal.SIGTERM] * 2 + [signal.SIGKILL]):
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
break
time.sleep(1)
try:
os.kill(pid, 0)
except ProcessLookupError:
break
try:
os.kill(pid, 0)
except ProcessLookupError:
self.log.warning("Stopped proxy at pid=%s", pid)
self._remove_pid_file()
return
else:
raise RuntimeError("Failed to stop proxy at pid=%s", pid)
def _write_pid_file(self):
"""write pid for proxy to a file"""
self.log.debug("Writing proxy pid file: %s", self.pid_file)
with open(self.pid_file, "w") as f:
f.write(str(self.proxy_process.pid))
def _remove_pid_file(self):
"""Cleanup pid file for proxy after stopping"""
if not self.pid_file:
return
self.log.debug("Removing proxy pid file %s", self.pid_file)
try:
os.remove(self.pid_file)
except FileNotFoundError:
self.log.debug("PID file %s already removed", self.pid_file)
pass
async def start(self):
"""Start the proxy process"""
# check if there is a previous instance still around
self._check_previous_process()
# build the command to launch
public_server = Server.from_url(self.public_url)
api_server = Server.from_url(self.api_url)
env = os.environ.copy()
@@ -496,6 +567,8 @@ class ConfigurableHTTPProxy(Proxy):
)
raise
self._write_pid_file()
def _check_process():
status = self.proxy_process.poll()
if status is not None:
@@ -519,13 +592,20 @@ class ConfigurableHTTPProxy(Proxy):
self._check_running_callback = pc
pc.start()
def _kill_proc_tree(self, pid):
import psutil
parent = psutil.Process(pid)
children = parent.children(recursive=True)
for child in children:
child.kill()
psutil.wait_procs(children, timeout=5)
def _terminate(self):
"""Terminate our process"""
if os.name == 'nt':
# On Windows we spawned a shell on Popen, so we need to
# terminate all child processes as well
import psutil
parent = psutil.Process(self.proxy_process.pid)
children = parent.children(recursive=True)
for child in children:
child.kill()
psutil.wait_procs(children, timeout=5)
else:
self.proxy_process.terminate()
def stop(self):
self.log.info("Cleaning up proxy[%i]...", self.proxy_process.pid)
@@ -533,14 +613,10 @@ class ConfigurableHTTPProxy(Proxy):
self._check_running_callback.stop()
if self.proxy_process.poll() is None:
try:
if os.name == 'nt':
# On Windows we spawned a shell on Popen, so we need to
# terminate all child processes as well
self._kill_proc_tree(self.proxy_process.pid)
else:
self.proxy_process.terminate()
self._terminate()
except Exception as e:
self.log.error("Failed to terminate proxy process: %s", e)
self._remove_pid_file()
async def check_running(self):
"""Check if the proxy is still running"""
@@ -549,6 +625,7 @@ class ConfigurableHTTPProxy(Proxy):
self.log.error("Proxy stopped with exit code %r",
'unknown' if self.proxy_process is None else self.proxy_process.poll()
)
self._remove_pid_file()
await self.start()
await self.restore_routes()

View File

@@ -39,6 +39,7 @@ A hub-managed service with no URL::
"""
import copy
import pipes
import shutil
from subprocess import Popen
@@ -304,6 +305,15 @@ class Service(LoggingConfigurable):
env['JUPYTERHUB_SERVICE_URL'] = self.url
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
hub = self.hub
if self.hub.ip in ('0.0.0.0', ''):
# if the Hub is listening on all interfaces,
# tell services to connect via localhost
# since they are always local subprocesses
hub = copy.deepcopy(self.hub)
hub.connect_url = ''
hub.connect_ip = '127.0.0.1'
self.spawner = _ServiceSpawner(
cmd=self.command,
environment=env,

View File

@@ -214,6 +214,9 @@ class SingleUserNotebookApp(NotebookApp):
version = __version__
classes = NotebookApp.classes + [HubOAuth]
# disable single-user app's localhost checking
allow_remote_access = True
# don't store cookie secrets
cookie_secret_file = ''
# always generate a new cookie secret on launch

View File

@@ -1002,7 +1002,7 @@ class LocalProcessSpawner(Spawner):
which could change what the jupyterhub-singleuser launch command does.
Only use this for trusted users.
"""
)
).tag(config=True)
proc = Instance(Popen,
allow_none=True,

View File

@@ -1055,7 +1055,7 @@ def test_get_proxy(app):
r = yield api_request(app, 'proxy')
r.raise_for_status()
reply = r.json()
assert list(reply.keys()) == ['/']
assert list(reply.keys()) == [app.hub.routespec]
@mark.gen_test

View File

@@ -216,11 +216,12 @@ def test_spawn_form(app):
orm_u = orm.User.find(app.db, 'jones')
u = app.users[orm_u]
yield u.stop()
r = yield async_requests.post(ujoin(base_url, 'spawn?next=/user/jones/tree'), cookies=cookies, data={
'bounds': ['-1', '1'],
'energy': '511keV',
})
next_url = ujoin(app.base_url, 'user/jones/tree')
r = yield async_requests.post(
url_concat(ujoin(base_url, 'spawn'), {'next': next_url}),
cookies=cookies,
data={'bounds': ['-1', '1'], 'energy': '511keV'},
)
r.raise_for_status()
assert r.history
assert u.spawner.user_options == {
@@ -236,13 +237,13 @@ def test_spawn_form_admin_access(app, admin_access):
base_url = ujoin(public_host(app), app.hub.base_url)
cookies = yield app.login_user('admin')
u = add_user(app.db, app=app, name='martha')
next_url = ujoin(app.base_url, 'user', u.name, 'tree')
r = yield async_requests.post(
ujoin(base_url, 'spawn/{0}?next=/user/{0}/tree'.format(u.name)),
cookies=cookies, data={
'bounds': ['-3', '3'],
'energy': '938MeV',
})
url_concat(ujoin(base_url, 'spawn', u.name), {'next': next_url}),
cookies=cookies,
data={'bounds': ['-3', '3'], 'energy': '938MeV'},
)
r.raise_for_status()
assert r.history
assert r.url.startswith(public_url(app, u))
@@ -570,3 +571,12 @@ def test_announcements(app, announcements):
app.authenticator.auto_login = auto_login
r.raise_for_status()
assert_announcement("logout", r.text)
@pytest.mark.gen_test
def test_server_not_running_api_request(app):
cookies = yield app.login_user("bees")
r = yield get_page("user/bees/api/status", app, hub=False, cookies=cookies)
assert r.status_code == 404
assert r.headers["content-type"] == "application/json"
assert r.json() == {"message": "bees is not running"}

View File

@@ -79,7 +79,7 @@ def test_external_proxy(request):
# test if api service has a root route '/'
routes = yield app.proxy.get_all_routes()
assert list(routes.keys()) == ['/']
assert list(routes.keys()) == [app.hub.routespec]
# add user to the db and start a single user server
name = 'river'
@@ -95,7 +95,7 @@ def test_external_proxy(request):
if app.subdomain_host:
host = '%s.%s' % (name, urlparse(app.subdomain_host).hostname)
user_spec = host + user_path
assert sorted(routes.keys()) == ['/', user_spec]
assert sorted(routes.keys()) == [app.hub.routespec, user_spec]
# teardown the proxy and start a new one in the same place
proxy.terminate()
@@ -113,7 +113,7 @@ def test_external_proxy(request):
# check that the routes are correct
routes = yield app.proxy.get_all_routes()
assert sorted(routes.keys()) == ['/', user_spec]
assert sorted(routes.keys()) == [app.hub.routespec, user_spec]
# teardown the proxy, and start a new one with different auth and port
proxy.terminate()
@@ -146,7 +146,7 @@ def test_external_proxy(request):
# check that the routes are correct
routes = yield app.proxy.get_all_routes()
assert sorted(routes.keys()) == ['/', user_spec]
assert sorted(routes.keys()) == [app.hub.routespec, user_spec]
@pytest.mark.gen_test

View File

@@ -53,20 +53,20 @@
{%- endif -%}
</td>
<td class="server-col col-sm-2 text-center">
<a role="button" class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</span>
<a role="button" class="start-server btn btn-xs btn-primary {% if u.running %}hidden{% endif %}">start server</span>
<a role="button" class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</a>
<a role="button" class="start-server btn btn-xs btn-primary {% if u.running %}hidden{% endif %}">start server</a>
</td>
<td class="server-col col-sm-1 text-center">
{% if admin_access %}
<a role="button" class="access-server btn btn-xs btn-primary {% if not u.running %}hidden{% endif %}">access server</span>
<a role="button" class="access-server btn btn-xs btn-primary {% if not u.running %}hidden{% endif %}">access server</a>
{% endif %}
</td>
<td class="edit-col col-sm-1 text-center">
<a role="button" class="edit-user btn btn-xs btn-primary">edit</span>
<a role="button" class="edit-user btn btn-xs btn-primary">edit</a>
</td>
<td class="edit-col col-sm-1 text-center">
{% if u.name != user.name %}
<a role="button" class="delete-user btn btn-xs btn-danger">delete</span>
<a role="button" class="delete-user btn btn-xs btn-danger">delete</a>
{% endif %}
</td>
{% endblock user_row %}

View File

@@ -15,6 +15,7 @@
</div>
</div>
<p id="progress-message"></p>
</div>
</div>
<div class="row">
<div class="col-md-8 col-md-offset-2">
@@ -23,6 +24,7 @@
<div id="progress-log"></div>
</details>
</div>
</div>
</div>
{% endblock %}

View File

@@ -23,7 +23,7 @@
require(["jquery"], function ($) {
$("#refresh").click(function () {
window.location.reload();
})
});
setTimeout(function () {
window.location.reload();
}, 5000);

View File

@@ -24,7 +24,6 @@
</div>
<div class="row">
<p>
<div id="token-area" class="col-md-6 col-md-offset-3" style="display: none;">
<div class="panel panel-default">
<div class="panel-heading">
@@ -41,7 +40,6 @@
</div>
</div>
</div>
</p>
</div>
{% if api_tokens %}
@@ -62,7 +60,7 @@
</thead>
<tbody>
{% for token in api_tokens %}
<tr class="token-row" data-token-id="{{token.api_id}}"">
<tr class="token-row" data-token-id="{{token.api_id}}">
{% block token_row scoped %}
<td class="note-col col-sm-5">{{token.note}}</td>
<td class="time-col col-sm-3">
@@ -111,7 +109,7 @@
<tbody>
{% for client in oauth_clients %}
<tr class="token-row"
data-token-id="{{ client['token_id'] }}"">
data-token-id="{{ client['token_id'] }}">
{% block client_row scoped %}
<td class="note-col col-sm-5">{{ client['description'] }}</td>
<td class="time-col col-sm-3">
@@ -129,8 +127,7 @@
{%- endif -%}
</td>
<td class="col-sm-1 text-center">
<button class="revoke-token-btn btn btn-xs btn-danger">revoke</a>
</button>
<button class="revoke-token-btn btn btn-xs btn-danger">revoke</button>
{% endblock client_row %}
</tr>
{% endfor %}
@@ -139,11 +136,11 @@
</div>
{% endif %}
</div>
{% endblock %}
{% endblock main %}
{% block script %}
{{ super() }}
<script type="text/javascript">
require(["token"]);
</script>
{% endblock %}
{% endblock script %}