Merge pull request #5001 from ellisonbg/dashboard-dirs

Add directory navigation to dashboard
This commit is contained in:
Min RK 2014-02-05 17:21:43 -08:00
commit cd7c1e6fae
21 changed files with 491 additions and 167 deletions

View File

@ -22,7 +22,6 @@ import json
import logging import logging
import os import os
import re import re
import stat
import sys import sys
import traceback import traceback
try: try:
@ -42,10 +41,7 @@ except ImportError:
from IPython.config import Application from IPython.config import Application
from IPython.utils.path import filefind from IPython.utils.path import filefind
from IPython.utils.py3compat import string_types from IPython.utils.py3compat import string_types
from IPython.html.utils import is_hidden
# UF_HIDDEN is a stat flag not defined in the stat module.
# It is used by BSD to indicate hidden files.
UF_HIDDEN = getattr(stat, 'UF_HIDDEN', 32768)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
# Top-level handlers # Top-level handlers
@ -269,28 +265,9 @@ class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
""" """
abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path) abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
abs_root = os.path.abspath(root) abs_root = os.path.abspath(root)
self.forbid_hidden(abs_root, abs_path) if is_hidden(abs_path, abs_root):
raise web.HTTPError(404)
return abs_path return abs_path
def forbid_hidden(self, absolute_root, absolute_path):
"""Raise 403 if a file is hidden or contained in a hidden directory.
Hidden is determined by either name starting with '.'
or the UF_HIDDEN flag as reported by stat
"""
inside_root = absolute_path[len(absolute_root):]
if any(part.startswith('.') for part in inside_root.split(os.sep)):
raise web.HTTPError(403)
# check UF_HIDDEN on any location up to root
path = absolute_path
while path and path.startswith(absolute_root) and path != absolute_root:
st = os.stat(path)
if getattr(st, 'st_flags', 0) & UF_HIDDEN:
raise web.HTTPError(403)
path = os.path.dirname(path)
return absolute_path
def json_errors(method): def json_errors(method):

View File

@ -29,6 +29,7 @@ from .nbmanager import NotebookManager
from IPython.nbformat import current from IPython.nbformat import current
from IPython.utils.traitlets import Unicode, Dict, Bool, TraitError from IPython.utils.traitlets import Unicode, Dict, Bool, TraitError
from IPython.utils import tz from IPython.utils import tz
from IPython.html.utils import is_hidden
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
# Classes # Classes
@ -108,7 +109,26 @@ class FileNotebookManager(NotebookManager):
path = path.strip('/') path = path.strip('/')
os_path = self.get_os_path(path=path) os_path = self.get_os_path(path=path)
return os.path.isdir(os_path) return os.path.isdir(os_path)
def is_hidden(self, path):
"""Does the API style path correspond to a hidden directory or file?
Parameters
----------
path : string
The path to check. This is an API path (`/` separated,
relative to base notebook-dir).
Returns
-------
exists : bool
Whether the path is hidden.
"""
path = path.strip('/')
os_path = self.get_os_path(path=path)
return is_hidden(os_path, self.notebook_dir)
def get_os_path(self, name=None, path=''): def get_os_path(self, name=None, path=''):
"""Given a notebook name and a URL path, return its file system """Given a notebook name and a URL path, return its file system
path. path.
@ -153,6 +173,47 @@ class FileNotebookManager(NotebookManager):
nbpath = self.get_os_path(name, path=path) nbpath = self.get_os_path(name, path=path)
return os.path.isfile(nbpath) return os.path.isfile(nbpath)
# TODO: Remove this after we create the contents web service and directories are
# no longer listed by the notebook web service.
def list_dirs(self, path):
"""List the directories for a given API style path."""
path = path.strip('/')
os_path = self.get_os_path('', path)
if not os.path.isdir(os_path) or is_hidden(os_path, self.notebook_dir):
raise web.HTTPError(404, u'directory does not exist: %r' % os_path)
dir_names = os.listdir(os_path)
dirs = []
for name in dir_names:
os_path = self.get_os_path(name, path)
if os.path.isdir(os_path) and not is_hidden(os_path, self.notebook_dir):
try:
model = self.get_dir_model(name, path)
except IOError:
pass
dirs.append(model)
dirs = sorted(dirs, key=lambda item: item['name'])
return dirs
# TODO: Remove this after we create the contents web service and directories are
# no longer listed by the notebook web service.
def get_dir_model(self, name, path=''):
"""Get the directory model given a directory name and its API style path"""
path = path.strip('/')
os_path = self.get_os_path(name, path)
if not os.path.isdir(os_path):
raise IOError('directory does not exist: %r' % os_path)
info = os.stat(os_path)
last_modified = tz.utcfromtimestamp(info.st_mtime)
created = tz.utcfromtimestamp(info.st_ctime)
# Create the notebook model.
model ={}
model['name'] = name
model['path'] = path
model['last_modified'] = last_modified
model['created'] = created
model['type'] = 'directory'
return model
def list_notebooks(self, path): def list_notebooks(self, path):
"""Returns a list of dictionaries that are the standard model """Returns a list of dictionaries that are the standard model
for all notebooks in the relative 'path'. for all notebooks in the relative 'path'.
@ -170,10 +231,7 @@ class FileNotebookManager(NotebookManager):
""" """
path = path.strip('/') path = path.strip('/')
notebook_names = self.get_notebook_names(path) notebook_names = self.get_notebook_names(path)
notebooks = [] notebooks = [self.get_notebook_model(name, path, content=False) for name in notebook_names]
for name in notebook_names:
model = self.get_notebook_model(name, path, content=False)
notebooks.append(model)
notebooks = sorted(notebooks, key=lambda item: item['name']) notebooks = sorted(notebooks, key=lambda item: item['name'])
return notebooks return notebooks
@ -207,6 +265,7 @@ class FileNotebookManager(NotebookManager):
model['path'] = path model['path'] = path
model['last_modified'] = last_modified model['last_modified'] = last_modified
model['created'] = created model['created'] = created
model['type'] = 'notebook'
if content: if content:
with io.open(os_path, 'r', encoding='utf-8') as f: with io.open(os_path, 'r', encoding='utf-8') as f:
try: try:
@ -223,7 +282,7 @@ class FileNotebookManager(NotebookManager):
if 'content' not in model: if 'content' not in model:
raise web.HTTPError(400, u'No notebook JSON data provided') raise web.HTTPError(400, u'No notebook JSON data provided')
# One checkpoint should always exist # One checkpoint should always exist
if self.notebook_exists(name, path) and not self.list_checkpoints(name, path): if self.notebook_exists(name, path) and not self.list_checkpoints(name, path):
self.create_checkpoint(name, path) self.create_checkpoint(name, path)

View File

@ -69,8 +69,18 @@ class NotebookHandler(IPythonHandler):
nbm = self.notebook_manager nbm = self.notebook_manager
# Check to see if a notebook name was given # Check to see if a notebook name was given
if name is None: if name is None:
# List notebooks in 'path' # TODO: Remove this after we create the contents web service and directories are
notebooks = nbm.list_notebooks(path) # no longer listed by the notebook web service. This should only handle notebooks
# and not directories.
dirs = nbm.list_dirs(path)
notebooks = []
index = []
for nb in nbm.list_notebooks(path):
if nb['name'].lower() == 'index.ipynb':
index.append(nb)
else:
notebooks.append(nb)
notebooks = index + dirs + notebooks
self.finish(json.dumps(notebooks, default=date_default)) self.finish(json.dumps(notebooks, default=date_default))
return return
# get and return notebook representation # get and return notebook representation

View File

@ -82,7 +82,24 @@ class NotebookManager(LoggingConfigurable):
Whether the path does indeed exist. Whether the path does indeed exist.
""" """
raise NotImplementedError raise NotImplementedError
def is_hidden(self, path):
"""Does the API style path correspond to a hidden directory or file?
Parameters
----------
path : string
The path to check. This is an API path (`/` separated,
relative to base notebook-dir).
Returns
-------
exists : bool
Whether the path is hidden.
"""
raise NotImplementedError
def _notebook_dir_changed(self, name, old, new): def _notebook_dir_changed(self, name, old, new):
"""Do a bit of validation of the notebook dir.""" """Do a bit of validation of the notebook dir."""
if not os.path.isabs(new): if not os.path.isabs(new):
@ -112,6 +129,26 @@ class NotebookManager(LoggingConfigurable):
""" """
return basename return basename
# TODO: Remove this after we create the contents web service and directories are
# no longer listed by the notebook web service.
def list_dirs(self, path):
"""List the directory models for a given API style path."""
raise NotImplementedError('must be implemented in a subclass')
# TODO: Remove this after we create the contents web service and directories are
# no longer listed by the notebook web service.
def get_dir_model(self, name, path=''):
"""Get the directory model given a directory name and its API style path.
The keys in the model should be:
* name
* path
* last_modified
* created
* type='directory'
"""
raise NotImplementedError('must be implemented in a subclass')
def list_notebooks(self, path=''): def list_notebooks(self, path=''):
"""Return a list of notebook dicts without content. """Return a list of notebook dicts without content.

View File

@ -15,6 +15,7 @@ from IPython.html.utils import url_path_join
from ..filenbmanager import FileNotebookManager from ..filenbmanager import FileNotebookManager
from ..nbmanager import NotebookManager from ..nbmanager import NotebookManager
class TestFileNotebookManager(TestCase): class TestFileNotebookManager(TestCase):
def test_nb_dir(self): def test_nb_dir(self):
@ -67,7 +68,7 @@ class TestNotebookManager(TestCase):
try: try:
os.makedirs(os_path) os.makedirs(os_path)
except OSError: except OSError:
print("Directory already exists.") print("Directory already exists: %r" % os_path)
def test_create_notebook_model(self): def test_create_notebook_model(self):
with TemporaryDirectory() as td: with TemporaryDirectory() as td:

View File

@ -21,6 +21,12 @@ from IPython.utils import py3compat
from IPython.utils.data import uniq_stable from IPython.utils.data import uniq_stable
# TODO: Remove this after we create the contents web service and directories are
# no longer listed by the notebook web service.
def notebooks_only(nb_list):
return [nb for nb in nb_list if nb['type']=='notebook']
class NBAPI(object): class NBAPI(object):
"""Wrapper for notebook API calls.""" """Wrapper for notebook API calls."""
def __init__(self, base_url): def __init__(self, base_url):
@ -125,25 +131,25 @@ class APITest(NotebookTestBase):
os.unlink(pjoin(nbdir, 'inroot.ipynb')) os.unlink(pjoin(nbdir, 'inroot.ipynb'))
def test_list_notebooks(self): def test_list_notebooks(self):
nbs = self.nb_api.list().json() nbs = notebooks_only(self.nb_api.list().json())
self.assertEqual(len(nbs), 1) self.assertEqual(len(nbs), 1)
self.assertEqual(nbs[0]['name'], 'inroot.ipynb') self.assertEqual(nbs[0]['name'], 'inroot.ipynb')
nbs = self.nb_api.list('/Directory with spaces in/').json() nbs = notebooks_only(self.nb_api.list('/Directory with spaces in/').json())
self.assertEqual(len(nbs), 1) self.assertEqual(len(nbs), 1)
self.assertEqual(nbs[0]['name'], 'inspace.ipynb') self.assertEqual(nbs[0]['name'], 'inspace.ipynb')
nbs = self.nb_api.list(u'/unicodé/').json() nbs = notebooks_only(self.nb_api.list(u'/unicodé/').json())
self.assertEqual(len(nbs), 1) self.assertEqual(len(nbs), 1)
self.assertEqual(nbs[0]['name'], 'innonascii.ipynb') self.assertEqual(nbs[0]['name'], 'innonascii.ipynb')
self.assertEqual(nbs[0]['path'], u'unicodé') self.assertEqual(nbs[0]['path'], u'unicodé')
nbs = self.nb_api.list('/foo/bar/').json() nbs = notebooks_only(self.nb_api.list('/foo/bar/').json())
self.assertEqual(len(nbs), 1) self.assertEqual(len(nbs), 1)
self.assertEqual(nbs[0]['name'], 'baz.ipynb') self.assertEqual(nbs[0]['name'], 'baz.ipynb')
self.assertEqual(nbs[0]['path'], 'foo/bar') self.assertEqual(nbs[0]['path'], 'foo/bar')
nbs = self.nb_api.list('foo').json() nbs = notebooks_only(self.nb_api.list('foo').json())
self.assertEqual(len(nbs), 4) self.assertEqual(len(nbs), 4)
nbnames = { normalize('NFC', n['name']) for n in nbs } nbnames = { normalize('NFC', n['name']) for n in nbs }
expected = [ u'a.ipynb', u'b.ipynb', u'name with spaces.ipynb', u'unicodé.ipynb'] expected = [ u'a.ipynb', u'b.ipynb', u'name with spaces.ipynb', u'unicodé.ipynb']
@ -231,7 +237,7 @@ class APITest(NotebookTestBase):
self.assertEqual(resp.status_code, 204) self.assertEqual(resp.status_code, 204)
for d in self.dirs + ['/']: for d in self.dirs + ['/']:
nbs = self.nb_api.list(d).json() nbs = notebooks_only(self.nb_api.list(d).json())
self.assertEqual(len(nbs), 0) self.assertEqual(len(nbs), 0)
def test_rename(self): def test_rename(self):
@ -240,7 +246,7 @@ class APITest(NotebookTestBase):
self.assertEqual(resp.json()['name'], 'z.ipynb') self.assertEqual(resp.json()['name'], 'z.ipynb')
assert os.path.isfile(pjoin(self.notebook_dir.name, 'foo', 'z.ipynb')) assert os.path.isfile(pjoin(self.notebook_dir.name, 'foo', 'z.ipynb'))
nbs = self.nb_api.list('foo').json() nbs = notebooks_only(self.nb_api.list('foo').json())
nbnames = set(n['name'] for n in nbs) nbnames = set(n['name'] for n in nbs)
self.assertIn('z.ipynb', nbnames) self.assertIn('z.ipynb', nbnames)
self.assertNotIn('a.ipynb', nbnames) self.assertNotIn('a.ipynb', nbnames)

View File

@ -28,22 +28,29 @@ div.traceback-wrapper{text-align:left;max-width:800px;margin:auto}
.center-nav{display:inline-block;margin-bottom:-4px} .center-nav{display:inline-block;margin-bottom:-4px}
.alternate_upload{background-color:none;display:inline} .alternate_upload{background-color:none;display:inline}
.alternate_upload.form{padding:0;margin:0} .alternate_upload.form{padding:0;margin:0}
.alternate_upload input.fileinput{background-color:#f00;position:relative;opacity:0;z-index:2;width:295px;margin-left:163px;cursor:pointer} .alternate_upload input.fileinput{background-color:#f00;position:relative;opacity:0;z-index:2;width:295px;margin-left:163px;cursor:pointer;height:26px}
.list_toolbar{padding:5px;height:25px;line-height:25px} ul#tabs{margin-bottom:4px}
.toolbar_info{float:left} ul#tabs a{padding-top:4px;padding-bottom:4px}
.toolbar_buttons{float:right} ul.breadcrumb a:focus,ul.breadcrumb a:hover{text-decoration:none}
ul.breadcrumb i.icon-home{font-size:16px;margin-right:4px}
ul.breadcrumb span{color:#5e5e5e}
.list_toolbar{padding:4px 0 4px 0}
.list_toolbar [class*="span"]{min-height:26px}
.list_header{font-weight:bold} .list_header{font-weight:bold}
.list_container{margin-top:16px;margin-bottom:16px;border:1px solid #ababab;border-radius:4px} .list_container{margin-top:4px;margin-bottom:20px;border:1px solid #ababab;border-radius:4px}
.list_container>div{border-bottom:1px solid #ababab}.list_container>div:hover .list-item{background-color:#f00} .list_container>div{border-bottom:1px solid #ababab}.list_container>div:hover .list-item{background-color:#f00}
.list_container>div:last-child{border:none} .list_container>div:last-child{border:none}
.list_item:hover .list_item{background-color:#ddd} .list_item:hover .list_item{background-color:#ddd}
.item_name{line-height:24px}
.list_container>div>span,.list_container>div>div{padding:8px}
.list_item a{text-decoration:none} .list_item a{text-decoration:none}
input.nbname_input{height:15px} .list_header>div,.list_item>div{padding-top:4px;padding-bottom:4px;padding-left:7px;padding-right:7px;height:22px;line-height:22px}
.item_name{line-height:22px;height:26px}
.item_icon{font-size:14px;color:#5e5e5e;margin-right:7px}
.item_buttons{line-height:1em}
.toolbar_info{height:26px;line-height:26px}
input.nbname_input,input.engine_num_input{padding-top:3px;padding-bottom:3px;height:14px;line-height:14px;margin:0}
input.engine_num_input{width:60px}
.highlight_text{color:#00f} .highlight_text{color:#00f}
#project_name>.breadcrumb{padding:0;margin-bottom:0;background-color:transparent;font-weight:bold} #project_name>.breadcrumb{padding:0;margin-bottom:0;background-color:transparent;font-weight:bold}
input.engine_num_input{height:20px;margin-bottom:2px;padding-top:0;padding-bottom:0;width:60px}
.ansibold{font-weight:bold} .ansibold{font-weight:bold}
.ansiblack{color:#000} .ansiblack{color:#000}
.ansired{color:#8b0000} .ansired{color:#8b0000}

View File

@ -1305,22 +1305,29 @@ span#login_widget{float:right}
.center-nav{display:inline-block;margin-bottom:-4px} .center-nav{display:inline-block;margin-bottom:-4px}
.alternate_upload{background-color:none;display:inline} .alternate_upload{background-color:none;display:inline}
.alternate_upload.form{padding:0;margin:0} .alternate_upload.form{padding:0;margin:0}
.alternate_upload input.fileinput{background-color:#f00;position:relative;opacity:0;z-index:2;width:295px;margin-left:163px;cursor:pointer} .alternate_upload input.fileinput{background-color:#f00;position:relative;opacity:0;z-index:2;width:295px;margin-left:163px;cursor:pointer;height:26px}
.list_toolbar{padding:5px;height:25px;line-height:25px} ul#tabs{margin-bottom:4px}
.toolbar_info{float:left} ul#tabs a{padding-top:4px;padding-bottom:4px}
.toolbar_buttons{float:right} ul.breadcrumb a:focus,ul.breadcrumb a:hover{text-decoration:none}
ul.breadcrumb i.icon-home{font-size:16px;margin-right:4px}
ul.breadcrumb span{color:#5e5e5e}
.list_toolbar{padding:4px 0 4px 0}
.list_toolbar [class*="span"]{min-height:26px}
.list_header{font-weight:bold} .list_header{font-weight:bold}
.list_container{margin-top:16px;margin-bottom:16px;border:1px solid #ababab;border-radius:4px} .list_container{margin-top:4px;margin-bottom:20px;border:1px solid #ababab;border-radius:4px}
.list_container>div{border-bottom:1px solid #ababab}.list_container>div:hover .list-item{background-color:#f00} .list_container>div{border-bottom:1px solid #ababab}.list_container>div:hover .list-item{background-color:#f00}
.list_container>div:last-child{border:none} .list_container>div:last-child{border:none}
.list_item:hover .list_item{background-color:#ddd} .list_item:hover .list_item{background-color:#ddd}
.item_name{line-height:24px}
.list_container>div>span,.list_container>div>div{padding:8px}
.list_item a{text-decoration:none} .list_item a{text-decoration:none}
input.nbname_input{height:15px} .list_header>div,.list_item>div{padding-top:4px;padding-bottom:4px;padding-left:7px;padding-right:7px;height:22px;line-height:22px}
.item_name{line-height:22px;height:26px}
.item_icon{font-size:14px;color:#5e5e5e;margin-right:7px}
.item_buttons{line-height:1em}
.toolbar_info{height:26px;line-height:26px}
input.nbname_input,input.engine_num_input{padding-top:3px;padding-bottom:3px;height:14px;line-height:14px;margin:0}
input.engine_num_input{width:60px}
.highlight_text{color:#00f} .highlight_text{color:#00f}
#project_name>.breadcrumb{padding:0;margin-bottom:0;background-color:transparent;font-weight:bold} #project_name>.breadcrumb{padding:0;margin-bottom:0;background-color:transparent;font-weight:bold}
input.engine_num_input{height:20px;margin-bottom:2px;padding-top:0;padding-bottom:0;width:60px}
.ansibold{font-weight:bold} .ansibold{font-weight:bold}
.ansiblack{color:#000} .ansiblack{color:#000}
.ansired{color:#8b0000} .ansired{color:#8b0000}

View File

@ -100,16 +100,16 @@ var IPython = (function (IPython) {
ClusterItem.prototype.state_stopped = function () { ClusterItem.prototype.state_stopped = function () {
var that = this; var that = this;
var profile_col = $('<span/>').addClass('profile_col span4').text(this.data.profile); var profile_col = $('<div/>').addClass('profile_col span4').text(this.data.profile);
var status_col = $('<span/>').addClass('status_col span3').text('stopped'); var status_col = $('<div/>').addClass('status_col span3').text('stopped');
var engines_col = $('<span/>').addClass('engine_col span3'); var engines_col = $('<div/>').addClass('engine_col span3');
var input = $('<input/>').attr('type','number') var input = $('<input/>').attr('type','number')
.attr('min',1) .attr('min',1)
.attr('size',3) .attr('size',3)
.addClass('engine_num_input'); .addClass('engine_num_input');
engines_col.append(input); engines_col.append(input);
var start_button = $('<button/>').addClass("btn btn-mini").text("Start"); var start_button = $('<button/>').addClass("btn btn-mini").text("Start");
var action_col = $('<span/>').addClass('action_col span2').append( var action_col = $('<div/>').addClass('action_col span2').append(
$("<span/>").addClass("item_buttons btn-group").append( $("<span/>").addClass("item_buttons btn-group").append(
start_button start_button
) )
@ -151,11 +151,11 @@ var IPython = (function (IPython) {
ClusterItem.prototype.state_running = function () { ClusterItem.prototype.state_running = function () {
var that = this; var that = this;
var profile_col = $('<span/>').addClass('profile_col span4').text(this.data.profile); var profile_col = $('<div/>').addClass('profile_col span4').text(this.data.profile);
var status_col = $('<span/>').addClass('status_col span3').text('running'); var status_col = $('<div/>').addClass('status_col span3').text('running');
var engines_col = $('<span/>').addClass('engines_col span3').text(this.data.n); var engines_col = $('<div/>').addClass('engines_col span3').text(this.data.n);
var stop_button = $('<button/>').addClass("btn btn-mini").text("Stop"); var stop_button = $('<button/>').addClass("btn btn-mini").text("Stop");
var action_col = $('<span/>').addClass('action_col span2').append( var action_col = $('<div/>').addClass('action_col span2').append(
$("<span/>").addClass("item_buttons btn-group").append( $("<span/>").addClass("item_buttons btn-group").append(
stop_button stop_button
) )

View File

@ -70,11 +70,10 @@ var IPython = (function (IPython) {
var reader = new FileReader(); var reader = new FileReader();
reader.readAsText(f); reader.readAsText(f);
var name_and_ext = utils.splitext(f.name); var name_and_ext = utils.splitext(f.name);
var nbname = name_and_ext[0];
var file_ext = name_and_ext[1]; var file_ext = name_and_ext[1];
if (file_ext === '.ipynb') { if (file_ext === '.ipynb') {
var item = that.new_notebook_item(0); var item = that.new_notebook_item(0);
that.add_name_input(nbname, item); that.add_name_input(f.name, item);
// Store the notebook item in the reader so we can use it later // Store the notebook item in the reader so we can use it later
// to know which item it belongs to. // to know which item it belongs to.
$(reader).data('item', item); $(reader).data('item', item);
@ -167,26 +166,37 @@ var IPython = (function (IPython) {
if (param !== undefined && param.msg) { if (param !== undefined && param.msg) {
message = param.msg; message = param.msg;
} }
var item = null;
var len = data.length; var len = data.length;
this.clear_list(); this.clear_list();
if (len === 0) { if (len === 0) {
$(this.new_notebook_item(0)) item = this.new_notebook_item(0);
.append( var span12 = item.children().first();
$('<div style="margin:auto;text-align:center;color:grey"/>') span12.empty();
.text(message) span12.append($('<div style="margin:auto;text-align:center;color:grey"/>').text(message));
); }
var path = this.notebookPath();
var offset = 0;
if (path !== '') {
item = this.new_notebook_item(0);
this.add_dir(path, '..', item);
offset = 1;
} }
for (var i=0; i<len; i++) { for (var i=0; i<len; i++) {
var name = data[i].name; if (data[i].type === 'directory') {
var path = this.notebookPath(); var name = data[i].name;
var nbname = utils.splitext(name)[0]; item = this.new_notebook_item(i+offset);
var item = this.new_notebook_item(i); this.add_dir(path, name, item);
this.add_link(path, nbname, item);
name = utils.url_path_join(path, name);
if(this.sessions[name] === undefined){
this.add_delete_button(item);
} else { } else {
this.add_shutdown_button(item,this.sessions[name]); var name = data[i].name;
item = this.new_notebook_item(i+offset);
this.add_link(path, name, item);
name = utils.url_path_join(path, name);
if(this.sessions[name] === undefined){
this.add_delete_button(item);
} else {
this.add_shutdown_button(item,this.sessions[name]);
}
} }
} }
}; };
@ -197,6 +207,8 @@ var IPython = (function (IPython) {
// item.addClass('list_item ui-widget ui-widget-content ui-helper-clearfix'); // item.addClass('list_item ui-widget ui-widget-content ui-helper-clearfix');
// item.css('border-top-style','none'); // item.css('border-top-style','none');
item.append($("<div/>").addClass("span12").append( item.append($("<div/>").addClass("span12").append(
$('<i/>').addClass('item_icon')
).append(
$("<a/>").addClass("item_link").append( $("<a/>").addClass("item_link").append(
$("<span/>").addClass("item_name") $("<span/>").addClass("item_name")
) )
@ -213,17 +225,35 @@ var IPython = (function (IPython) {
}; };
NotebookList.prototype.add_dir = function (path, name, item) {
item.data('name', name);
item.data('path', path);
item.find(".item_name").text(name);
item.find(".item_icon").addClass('icon-folder-open');
item.find("a.item_link")
.attr('href',
utils.url_join_encode(
this.baseProjectUrl(),
"tree",
path,
name
)
);
};
NotebookList.prototype.add_link = function (path, nbname, item) { NotebookList.prototype.add_link = function (path, nbname, item) {
item.data('nbname', nbname); item.data('nbname', nbname);
item.data('path', path); item.data('path', path);
item.find(".item_name").text(nbname); item.find(".item_name").text(nbname);
item.find(".item_icon").addClass('icon-book');
item.find("a.item_link") item.find("a.item_link")
.attr('href', .attr('href',
utils.url_join_encode( utils.url_join_encode(
this.baseProjectUrl(), this.baseProjectUrl(),
"notebooks", "notebooks",
path, path,
nbname + ".ipynb" nbname
) )
).attr('target','_blank'); ).attr('target','_blank');
}; };
@ -231,10 +261,11 @@ var IPython = (function (IPython) {
NotebookList.prototype.add_name_input = function (nbname, item) { NotebookList.prototype.add_name_input = function (nbname, item) {
item.data('nbname', nbname); item.data('nbname', nbname);
item.find(".item_icon").addClass('icon-book');
item.find(".item_name").empty().append( item.find(".item_name").empty().append(
$('<input/>') $('<input/>')
.addClass("nbname_input") .addClass("nbname_input")
.attr('value', nbname) .attr('value', utils.splitext(nbname)[0])
.attr('size', '30') .attr('size', '30')
.attr('type', 'text') .attr('type', 'text')
); );
@ -248,7 +279,7 @@ var IPython = (function (IPython) {
NotebookList.prototype.add_shutdown_button = function (item, session) { NotebookList.prototype.add_shutdown_button = function (item, session) {
var that = this; var that = this;
var shutdown_button = $("<button/>").text("Shutdown").addClass("btn btn-mini"). var shutdown_button = $("<button/>").text("Shutdown").addClass("btn btn-mini btn-danger").
click(function (e) { click(function (e) {
var settings = { var settings = {
processData : false, processData : false,
@ -303,7 +334,7 @@ var IPython = (function (IPython) {
notebooklist.baseProjectUrl(), notebooklist.baseProjectUrl(),
'api/notebooks', 'api/notebooks',
notebooklist.notebookPath(), notebooklist.notebookPath(),
nbname + '.ipynb' nbname
); );
$.ajax(url, settings); $.ajax(url, settings);
} }
@ -323,6 +354,9 @@ var IPython = (function (IPython) {
.addClass('btn btn-primary btn-mini upload_button') .addClass('btn btn-primary btn-mini upload_button')
.click(function (e) { .click(function (e) {
var nbname = item.find('.item_name > input').val(); var nbname = item.find('.item_name > input').val();
if (nbname.slice(nbname.length-6, nbname.length) != ".ipynb") {
nbname = nbname + ".ipynb";
}
var path = that.notebookPath(); var path = that.notebookPath();
var nbdata = item.data('nbdata'); var nbdata = item.data('nbdata');
var content_type = 'application/json'; var content_type = 'application/json';
@ -349,7 +383,7 @@ var IPython = (function (IPython) {
that.baseProjectUrl(), that.baseProjectUrl(),
'api/notebooks', 'api/notebooks',
that.notebookPath(), that.notebookPath(),
nbname + '.ipynb' nbname
); );
$.ajax(url, settings); $.ajax(url, settings);
return false; return false;

View File

@ -22,4 +22,5 @@
width: 295px; width: 295px;
margin-left:163px; margin-left:163px;
cursor: pointer; cursor: pointer;
height: 26px;
} }

View File

@ -5,21 +5,43 @@
* Author: IPython Development Team * Author: IPython Development Team
*/ */
#tabs { @dashboard_tb_pad: 4px;
@dashboard_lr_pad: 7px;
// These are the total heights of the Bootstrap small and mini buttons. These values
// are not less variables so we have to track them statically.
@btn_small_height: 26px;
@btn_mini_height: 22px;
@dark_dashboard_color: darken(@border_color, 30%);
ul#tabs {
margin-bottom: @dashboard_tb_pad;
}
ul#tabs a {
padding-top: @dashboard_tb_pad;
padding-bottom: @dashboard_tb_pad;
}
ul.breadcrumb {
a:focus, a:hover {
text-decoration: none;
}
i.icon-home {
font-size: 16px;
margin-right: 4px;
}
span {
color: @dark_dashboard_color;
}
} }
.list_toolbar { .list_toolbar {
padding: 5px; padding: @dashboard_tb_pad 0 @dashboard_tb_pad 0;
height: 25px;
line-height: 25px;
} }
.toolbar_info { .list_toolbar [class*="span"] {
float: left; min-height: @btn_small_height;
}
.toolbar_buttons {
float: right;
} }
.list_header { .list_header {
@ -27,8 +49,8 @@
} }
.list_container { .list_container {
margin-top: 16px; margin-top: @dashboard_tb_pad;
margin-bottom: 16px; margin-bottom: 5*@dashboard_tb_pad;
border: 1px solid @border_color; border: 1px solid @border_color;
border-radius: 4px; border-radius: 4px;
} }
@ -48,42 +70,55 @@
&:hover .list_item { &:hover .list_item {
background-color: #ddd; background-color: #ddd;
}; };
a {text-decoration: none;}
}
.list_header>div, .list_item>div {
padding-top: @dashboard_tb_pad;
padding-bottom: @dashboard_tb_pad;
padding-left: @dashboard_lr_pad;
padding-right: @dashboard_lr_pad;
height: @btn_mini_height;
line-height: @btn_mini_height;
} }
.item_name { .item_name {
line-height: 24px; line-height: @btn_mini_height;
height: @btn_small_height;
} }
.list_container > div > span, .list_container > div > div { .item_icon {
padding: 8px; font-size: 14px;
color: @dark_dashboard_color;
margin-right: @dashboard_lr_pad;
} }
.item_buttons {
.list_item a { line-height: 1em;
text-decoration: none;
} }
.profile_col { .toolbar_info {
height: @btn_small_height;
line-height: @btn_small_height;
} }
.status_col { input.nbname_input, input.engine_num_input {
// These settings give these inputs a height that matches @btn_mini_height = 22
padding-top: 3px;
padding-bottom: 3px;
height: 14px;
line-height: 14px;
margin: 0px;
} }
.engines_col { input.engine_num_input {
} width: 60px;
.action_col {
}
input.nbname_input {
height: 15px;
} }
.highlight_text { .highlight_text {
color: blue; color: blue;
} }
#project_name > .breadcrumb { #project_name > .breadcrumb {
padding: 0px; padding: 0px;
margin-bottom: 0px; margin-bottom: 0px;
@ -92,10 +127,4 @@ input.nbname_input {
} }
input.engine_num_input {
height: 20px;
margin-bottom:2px;
padding-top:0;
padding-bottom:0;
width: 60px;
}

View File

@ -1,6 +1,6 @@
{% extends "page.html" %} {% extends "page.html" %}
{% block title %}IPython Dashboard{% endblock %} {% block title %}{{page_title}}{% endblock %}
{% block stylesheet %} {% block stylesheet %}
@ -22,33 +22,38 @@ data-base-kernel-url="{{base_kernel_url}}"
<div id="ipython-main-app" class="container"> <div id="ipython-main-app" class="container">
<div id="tabs" class="tabbable"> <div id="tab_content" class="tabbable">
<ul class="nav nav-tabs" id="tabs"> <ul id="tabs" class="nav nav-tabs">
<li class="active"><a href="#notebooks" data-toggle="tab">Notebooks</a></li> <li class="active"><a href="#notebooks" data-toggle="tab">Notebooks</a></li>
<li><a href="#clusters" data-toggle="tab">Clusters</a></li> <li><a href="#clusters" data-toggle="tab">Clusters</a></li>
</ul> </ul>
<div class="tab-content"> <div class="tab-content">
<div id="notebooks" class="tab-pane active"> <div id="notebooks" class="tab-pane active">
<div id="notebook_toolbar"> <div id="notebook_toolbar" class="row-fluid">
<form id='alternate_upload' class='alternate_upload' > <div class="span8">
<span id="drag_info" style="position:absolute" > <form id='alternate_upload' class='alternate_upload' >
To import a notebook, drag the file onto the listing below or <strong>click here</strong>. <span id="drag_info" style="position:absolute" >
</span> To import a notebook, drag the file onto the listing below or <strong>click here</strong>.
<input type="file" name="datafile" class="fileinput" multiple='multiple'> </span>
</form> <input type="file" name="datafile" class="fileinput" multiple='multiple'>
<span id="notebook_buttons"> </form>
<button id="refresh_notebook_list" title="Refresh notebook list" class="btn btn-small">Refresh</button> </div>
<button id="new_notebook" title="Create new notebook" class="btn btn-small">New Notebook</button> <div class="span4 clearfix">
</span> <span id="notebook_buttons" class="pull-right">
<button id="new_notebook" title="Create new notebook" class="btn btn-small">New Notebook</button>
<button id="refresh_notebook_list" title="Refresh notebook list" class="btn btn-small"><i class="icon-refresh"></i></button>
</span>
</div>
</div> </div>
<div id="notebook_list"> <div id="notebook_list">
<div id="notebook_list_header" class="row-fluid list_header"> <div id="notebook_list_header" class="row-fluid list_header">
<div id="project_name"> <div id="project_name">
<ul class="breadcrumb"> <ul class="breadcrumb">
{% for component in tree_url_path.strip('/').split('/') %} <li><a href="{{breadcrumbs[0][0]}}"><i class="icon-home"></i></a><span>/</span></li>
<li>{{component}} <span>/</span></li> {% for crumb in breadcrumbs[1:] %}
<li><a href="{{crumb[0]}}">{{crumb[1]}}</a> <span>/</span></li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
@ -58,20 +63,23 @@ data-base-kernel-url="{{base_kernel_url}}"
<div id="clusters" class="tab-pane"> <div id="clusters" class="tab-pane">
<div id="cluster_toolbar"> <div id="cluster_toolbar" class="row-fluid">
<span id="cluster_list_info">IPython parallel computing clusters</span> <div class="span8">
<span id="cluster_list_info">IPython parallel computing clusters</span>
<span id="cluster_buttons"> </div>
<button id="refresh_cluster_list" title="Refresh cluster list" class="btn btn-small">Refresh</button> <div class="span4" class="clearfix">
</span> <span id="cluster_buttons" class="pull-right">
<button id="refresh_cluster_list" title="Refresh cluster list" class="btn btn-small"><i class="icon-refresh"></i></button>
</span>
</div>
</div> </div>
<div id="cluster_list"> <div id="cluster_list">
<div id="cluster_list_header" class="row-fluid list_header"> <div id="cluster_list_header" class="row-fluid list_header">
<span class="profile_col span4">profile</span> <div class="profile_col span4">profile</div>
<span class="status_col span3">status</span> <div class="status_col span3">status</div>
<span class="engines_col span3" title="Enter the number of engines to start or empty for default"># of engines</span> <div class="engines_col span3" title="Enter the number of engines to start or empty for default"># of engines</div>
<span class="action_col span2">action</span> <div class="action_col span2">action</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,37 @@
casper.get_list_items = function () {
return this.evaluate(function () {
return $.makeArray($('.item_link').map(function () {
return {
link: $(this).attr('href'),
label: $(this).find('.item_name').text()
}
}));
});
}
casper.test_items = function (baseUrl) {
casper.then(function () {
var items = casper.get_list_items();
casper.each(items, function (self, item) {
if (!item.label.match('.ipynb$')) {
var followed_url = baseUrl+item.link;
if (!followed_url.match('/\.\.$')) {
casper.thenOpen(baseUrl+item.link, function () {
casper.wait_for_dashboard();
this.test.assertEquals(this.getCurrentUrl(), followed_url, 'Testing dashboard link: '+followed_url);
casper.test_items(baseUrl);
this.back();
});
}
}
});
});
}
casper.dashboard_test(function () {
baseUrl = this.get_notebook_server()
casper.test_items(baseUrl);
})

View File

@ -250,6 +250,34 @@ casper.notebook_test = function(test) {
}); });
}; };
casper.wait_for_dashboard = function () {
// Wait for the dashboard list to load.
casper.waitForSelector('.list_item');
}
casper.open_dashboard = function () {
// Start casper by opening the dashboard page.
var baseUrl = this.get_notebook_server();
this.start(baseUrl);
this.wait_for_dashboard();
}
casper.dashboard_test = function (test) {
// Open the dashboard page and run a test.
this.open_dashboard();
this.then(test);
this.then(function () {
this.page.close();
this.page = null;
});
// Run the browser automation.
this.run(function() {
this.test.done();
});
}
casper.options.waitTimeout=10000 casper.options.waitTimeout=10000
casper.on('waitFor.timeout', function onWaitForTimeout(timeout) { casper.on('waitFor.timeout', function onWaitForTimeout(timeout) {
this.echo("Timeout for " + casper.get_notebook_server()); this.echo("Timeout for " + casper.get_notebook_server());

View File

@ -1,5 +1,7 @@
"""Base class for notebook tests.""" """Base class for notebook tests."""
from __future__ import print_function
import sys import sys
import time import time
import requests import requests

View File

@ -42,13 +42,13 @@ class FilesTest(NotebookTestBase):
r.raise_for_status() r.raise_for_status()
self.assertEqual(r.text, 'foo') self.assertEqual(r.text, 'foo')
r = requests.get(url_path_join(url, 'files', d, '.foo')) r = requests.get(url_path_join(url, 'files', d, '.foo'))
self.assertEqual(r.status_code, 403) self.assertEqual(r.status_code, 404)
for d in hidden: for d in hidden:
path = pjoin(nbdir, d.replace('/', os.sep)) path = pjoin(nbdir, d.replace('/', os.sep))
for foo in ('foo', '.foo'): for foo in ('foo', '.foo'):
r = requests.get(url_path_join(url, 'files', d, foo)) r = requests.get(url_path_join(url, 'files', d, foo))
self.assertEqual(r.status_code, 403) self.assertEqual(r.status_code, 404)
def test_old_files_redirect(self): def test_old_files_redirect(self):
"""pre-2.0 'files/' prefixed links are properly redirected""" """pre-2.0 'files/' prefixed links are properly redirected"""

View File

@ -11,10 +11,13 @@
# Imports # Imports
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
import os
import nose.tools as nt import nose.tools as nt
import IPython.testing.tools as tt import IPython.testing.tools as tt
from IPython.html.utils import url_escape, url_unescape from IPython.html.utils import url_escape, url_unescape, is_hidden
from IPython.utils.tempdir import TemporaryDirectory
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
# Test functions # Test functions
@ -59,3 +62,15 @@ def test_url_unescape():
'/%20%21%40%24%23%25%5E%26%2A%20/%20test%20%25%5E%20notebook%20%40%23%24%20name.ipynb') '/%20%21%40%24%23%25%5E%26%2A%20/%20test%20%25%5E%20notebook%20%40%23%24%20name.ipynb')
nt.assert_equal(path, '/ !@$#%^&* / test %^ notebook @#$ name.ipynb') nt.assert_equal(path, '/ !@$#%^&* / test %^ notebook @#$ name.ipynb')
def test_is_hidden():
with TemporaryDirectory() as root:
subdir1 = os.path.join(root, 'subdir')
os.makedirs(subdir1)
nt.assert_equal(is_hidden(subdir1, root), False)
subdir2 = os.path.join(root, '.subdir2')
os.makedirs(subdir2)
nt.assert_equal(is_hidden(subdir2, root), True)
subdir34 = os.path.join(root, 'subdir3', '.subdir4')
os.makedirs(subdir34)
nt.assert_equal(is_hidden(subdir34, root), True)
nt.assert_equal(is_hidden(subdir34), True)

View File

@ -19,7 +19,7 @@ import os
from tornado import web from tornado import web
from ..base.handlers import IPythonHandler, notebook_path_regex, path_regex from ..base.handlers import IPythonHandler, notebook_path_regex, path_regex
from ..utils import url_path_join, path2url, url2path, url_escape from ..utils import url_path_join, path2url, url2path, url_escape, is_hidden
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
# Handlers # Handlers
@ -29,6 +29,27 @@ from ..utils import url_path_join, path2url, url2path, url_escape
class TreeHandler(IPythonHandler): class TreeHandler(IPythonHandler):
"""Render the tree view, listing notebooks, clusters, etc.""" """Render the tree view, listing notebooks, clusters, etc."""
def generate_breadcrumbs(self, path):
breadcrumbs = [(url_escape(url_path_join(self.base_project_url, 'tree')), '')]
comps = path.split('/')
ncomps = len(comps)
for i in range(ncomps):
if comps[i]:
link = url_escape(url_path_join(self.base_project_url, 'tree', *comps[0:i+1]))
breadcrumbs.append((link, comps[i]))
return breadcrumbs
def generate_page_title(self, path):
comps = path.split('/')
if len(comps) > 3:
for i in range(len(comps)-2):
comps.pop(0)
page_title = url_escape(url_path_join(*comps))
if page_title:
return page_title+'/'
else:
return 'Home'
@web.authenticated @web.authenticated
def get(self, path='', name=None): def get(self, path='', name=None):
path = path.strip('/') path = path.strip('/')
@ -41,13 +62,16 @@ class TreeHandler(IPythonHandler):
self.log.debug("Redirecting %s to %s", self.request.path, url) self.log.debug("Redirecting %s to %s", self.request.path, url)
self.redirect(url) self.redirect(url)
else: else:
if not nbm.path_exists(path=path): if not nbm.path_exists(path=path) or nbm.is_hidden(path):
# no such directory, 404 # Directory is hidden or does not exist.
raise web.HTTPError(404) raise web.HTTPError(404)
breadcrumbs = self.generate_breadcrumbs(path)
page_title = self.generate_page_title(path)
self.write(self.render_template('tree.html', self.write(self.render_template('tree.html',
project=self.project_dir, project=self.project_dir,
tree_url_path=path, page_title=page_title,
notebook_path=path, notebook_path=path,
breadcrumbs=breadcrumbs
)) ))

View File

@ -12,7 +12,11 @@ Authors:
# the file COPYING, distributed as part of this software. # the file COPYING, distributed as part of this software.
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
from __future__ import print_function
import os import os
import stat
try: try:
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
except ImportError: except ImportError:
@ -20,6 +24,10 @@ except ImportError:
from IPython.utils import py3compat from IPython.utils import py3compat
# UF_HIDDEN is a stat flag not defined in the stat module.
# It is used by BSD to indicate hidden files.
UF_HIDDEN = getattr(stat, 'UF_HIDDEN', 32768)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
# Imports # Imports
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
@ -72,3 +80,35 @@ def url_unescape(path):
for p in py3compat.unicode_to_str(path).split('/') for p in py3compat.unicode_to_str(path).split('/')
]) ])
def is_hidden(abs_path, abs_root=''):
"""Is a file is hidden or contained in a hidden directory.
This will start with the rightmost path element and work backwards to the
given root to see if a path is hidden or in a hidden directory. Hidden is
determined by either name starting with '.' or the UF_HIDDEN flag as
reported by stat.
Parameters
----------
abs_path : unicode
The absolute path to check for hidden directories.
abs_root : unicode
The absolute path of the root directory in which hidden directories
should be checked for.
"""
if not abs_root:
abs_root = abs_path.split(os.sep, 1)[0] + os.sep
inside_root = abs_path[len(abs_root):]
if any(part.startswith('.') for part in inside_root.split(os.sep)):
return True
# check UF_HIDDEN on any location up to root
path = abs_path
while path and path.startswith(abs_root) and path != abs_root:
st = os.stat(path)
if getattr(st, 'st_flags', 0) & UF_HIDDEN:
return True
path = os.path.dirname(path)
return False

View File

@ -167,9 +167,12 @@ class JSController(TestController):
self.section = section self.section = section
self.ipydir = TemporaryDirectory() self.ipydir = TemporaryDirectory()
# print(self.ipydir.name) self.nbdir = TemporaryDirectory()
print("Running notebook tests in directory: %r" % self.nbdir.name)
os.makedirs(os.path.join(self.nbdir.name, os.path.join('subdir1', 'subdir1a')))
os.makedirs(os.path.join(self.nbdir.name, os.path.join('subdir2', 'subdir2a')))
self.dirs.append(self.ipydir) self.dirs.append(self.ipydir)
self.env['IPYTHONDIR'] = self.ipydir.name self.dirs.append(self.nbdir)
def launch(self): def launch(self):
# start the ipython notebook, so we get the port number # start the ipython notebook, so we get the port number
@ -191,7 +194,7 @@ class JSController(TestController):
def _init_server(self): def _init_server(self):
"Start the notebook server in a separate process" "Start the notebook server in a separate process"
self.queue = q = Queue() self.queue = q = Queue()
self.server = Process(target=run_webapp, args=(q, self.ipydir.name)) self.server = Process(target=run_webapp, args=(q, self.ipydir.name, self.nbdir.name))
self.server.start() self.server.start()
self.server_port = q.get() self.server_port = q.get()
@ -202,18 +205,17 @@ class JSController(TestController):
js_test_group_names = {'js'} js_test_group_names = {'js'}
def run_webapp(q, nbdir, loglevel=0): def run_webapp(q, ipydir, nbdir, loglevel=0):
"""start the IPython Notebook, and pass port back to the queue""" """start the IPython Notebook, and pass port back to the queue"""
import os import os
import IPython.html.notebookapp as nbapp import IPython.html.notebookapp as nbapp
import sys import sys
sys.stderr = open(os.devnull, 'w') sys.stderr = open(os.devnull, 'w')
os.environ["IPYTHONDIR"] = nbdir
server = nbapp.NotebookApp() server = nbapp.NotebookApp()
args = ['--no-browser'] args = ['--no-browser']
args.append('--notebook-dir='+nbdir) args.extend(['--ipython-dir', ipydir])
args.append('--profile-dir='+nbdir) args.extend(['--notebook-dir', nbdir])
args.append('--log-level='+str(loglevel)) args.extend(['--log-level', str(loglevel)])
server.initialize(args) server.initialize(args)
# communicate the port number to the parent process # communicate the port number to the parent process
q.put(server.port) q.put(server.port)