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 os
import re
import stat
import sys
import traceback
try:
@ -42,10 +41,7 @@ except ImportError:
from IPython.config import Application
from IPython.utils.path import filefind
from IPython.utils.py3compat import string_types
# 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)
from IPython.html.utils import is_hidden
#-----------------------------------------------------------------------------
# Top-level handlers
@ -269,28 +265,9 @@ class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
"""
abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
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
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):

View File

@ -29,6 +29,7 @@ from .nbmanager import NotebookManager
from IPython.nbformat import current
from IPython.utils.traitlets import Unicode, Dict, Bool, TraitError
from IPython.utils import tz
from IPython.html.utils import is_hidden
#-----------------------------------------------------------------------------
# Classes
@ -108,7 +109,26 @@ class FileNotebookManager(NotebookManager):
path = path.strip('/')
os_path = self.get_os_path(path=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=''):
"""Given a notebook name and a URL path, return its file system
path.
@ -153,6 +173,47 @@ class FileNotebookManager(NotebookManager):
nbpath = self.get_os_path(name, path=path)
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):
"""Returns a list of dictionaries that are the standard model
for all notebooks in the relative 'path'.
@ -170,10 +231,7 @@ class FileNotebookManager(NotebookManager):
"""
path = path.strip('/')
notebook_names = self.get_notebook_names(path)
notebooks = []
for name in notebook_names:
model = self.get_notebook_model(name, path, content=False)
notebooks.append(model)
notebooks = [self.get_notebook_model(name, path, content=False) for name in notebook_names]
notebooks = sorted(notebooks, key=lambda item: item['name'])
return notebooks
@ -207,6 +265,7 @@ class FileNotebookManager(NotebookManager):
model['path'] = path
model['last_modified'] = last_modified
model['created'] = created
model['type'] = 'notebook'
if content:
with io.open(os_path, 'r', encoding='utf-8') as f:
try:
@ -223,7 +282,7 @@ class FileNotebookManager(NotebookManager):
if 'content' not in model:
raise web.HTTPError(400, u'No notebook JSON data provided')
# One checkpoint should always exist
if self.notebook_exists(name, path) and not self.list_checkpoints(name, path):
self.create_checkpoint(name, path)

View File

@ -69,8 +69,18 @@ class NotebookHandler(IPythonHandler):
nbm = self.notebook_manager
# Check to see if a notebook name was given
if name is None:
# List notebooks in 'path'
notebooks = nbm.list_notebooks(path)
# TODO: Remove this after we create the contents web service and directories are
# 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))
return
# get and return notebook representation

View File

@ -82,7 +82,24 @@ class NotebookManager(LoggingConfigurable):
Whether the path does indeed exist.
"""
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):
"""Do a bit of validation of the notebook dir."""
if not os.path.isabs(new):
@ -112,6 +129,26 @@ class NotebookManager(LoggingConfigurable):
"""
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=''):
"""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 ..nbmanager import NotebookManager
class TestFileNotebookManager(TestCase):
def test_nb_dir(self):
@ -67,7 +68,7 @@ class TestNotebookManager(TestCase):
try:
os.makedirs(os_path)
except OSError:
print("Directory already exists.")
print("Directory already exists: %r" % os_path)
def test_create_notebook_model(self):
with TemporaryDirectory() as td:

View File

@ -21,6 +21,12 @@ from IPython.utils import py3compat
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):
"""Wrapper for notebook API calls."""
def __init__(self, base_url):
@ -125,25 +131,25 @@ class APITest(NotebookTestBase):
os.unlink(pjoin(nbdir, 'inroot.ipynb'))
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(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(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(nbs[0]['name'], 'innonascii.ipynb')
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(nbs[0]['name'], 'baz.ipynb')
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)
nbnames = { normalize('NFC', n['name']) for n in nbs }
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)
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)
def test_rename(self):
@ -240,7 +246,7 @@ class APITest(NotebookTestBase):
self.assertEqual(resp.json()['name'], '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)
self.assertIn('z.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}
.alternate_upload{background-color:none;display:inline}
.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}
.list_toolbar{padding:5px;height:25px;line-height:25px}
.toolbar_info{float:left}
.toolbar_buttons{float:right}
.alternate_upload input.fileinput{background-color:#f00;position:relative;opacity:0;z-index:2;width:295px;margin-left:163px;cursor:pointer;height:26px}
ul#tabs{margin-bottom:4px}
ul#tabs a{padding-top:4px;padding-bottom:4px}
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_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:last-child{border:none}
.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}
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}
#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}
.ansiblack{color:#000}
.ansired{color:#8b0000}

View File

@ -1305,22 +1305,29 @@ span#login_widget{float:right}
.center-nav{display:inline-block;margin-bottom:-4px}
.alternate_upload{background-color:none;display:inline}
.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}
.list_toolbar{padding:5px;height:25px;line-height:25px}
.toolbar_info{float:left}
.toolbar_buttons{float:right}
.alternate_upload input.fileinput{background-color:#f00;position:relative;opacity:0;z-index:2;width:295px;margin-left:163px;cursor:pointer;height:26px}
ul#tabs{margin-bottom:4px}
ul#tabs a{padding-top:4px;padding-bottom:4px}
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_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:last-child{border:none}
.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}
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}
#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}
.ansiblack{color:#000}
.ansired{color:#8b0000}

View File

@ -100,16 +100,16 @@ var IPython = (function (IPython) {
ClusterItem.prototype.state_stopped = function () {
var that = this;
var profile_col = $('<span/>').addClass('profile_col span4').text(this.data.profile);
var status_col = $('<span/>').addClass('status_col span3').text('stopped');
var engines_col = $('<span/>').addClass('engine_col span3');
var profile_col = $('<div/>').addClass('profile_col span4').text(this.data.profile);
var status_col = $('<div/>').addClass('status_col span3').text('stopped');
var engines_col = $('<div/>').addClass('engine_col span3');
var input = $('<input/>').attr('type','number')
.attr('min',1)
.attr('size',3)
.addClass('engine_num_input');
engines_col.append(input);
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(
start_button
)
@ -151,11 +151,11 @@ var IPython = (function (IPython) {
ClusterItem.prototype.state_running = function () {
var that = this;
var profile_col = $('<span/>').addClass('profile_col span4').text(this.data.profile);
var status_col = $('<span/>').addClass('status_col span3').text('running');
var engines_col = $('<span/>').addClass('engines_col span3').text(this.data.n);
var profile_col = $('<div/>').addClass('profile_col span4').text(this.data.profile);
var status_col = $('<div/>').addClass('status_col span3').text('running');
var engines_col = $('<div/>').addClass('engines_col span3').text(this.data.n);
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(
stop_button
)

View File

@ -70,11 +70,10 @@ var IPython = (function (IPython) {
var reader = new FileReader();
reader.readAsText(f);
var name_and_ext = utils.splitext(f.name);
var nbname = name_and_ext[0];
var file_ext = name_and_ext[1];
if (file_ext === '.ipynb') {
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
// to know which item it belongs to.
$(reader).data('item', item);
@ -167,26 +166,37 @@ var IPython = (function (IPython) {
if (param !== undefined && param.msg) {
message = param.msg;
}
var item = null;
var len = data.length;
this.clear_list();
if (len === 0) {
$(this.new_notebook_item(0))
.append(
$('<div style="margin:auto;text-align:center;color:grey"/>')
.text(message)
);
item = this.new_notebook_item(0);
var span12 = item.children().first();
span12.empty();
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++) {
var name = data[i].name;
var path = this.notebookPath();
var nbname = utils.splitext(name)[0];
var item = this.new_notebook_item(i);
this.add_link(path, nbname, item);
name = utils.url_path_join(path, name);
if(this.sessions[name] === undefined){
this.add_delete_button(item);
if (data[i].type === 'directory') {
var name = data[i].name;
item = this.new_notebook_item(i+offset);
this.add_dir(path, name, item);
} 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.css('border-top-style','none');
item.append($("<div/>").addClass("span12").append(
$('<i/>').addClass('item_icon')
).append(
$("<a/>").addClass("item_link").append(
$("<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) {
item.data('nbname', nbname);
item.data('path', path);
item.find(".item_name").text(nbname);
item.find(".item_icon").addClass('icon-book');
item.find("a.item_link")
.attr('href',
utils.url_join_encode(
this.baseProjectUrl(),
"notebooks",
path,
nbname + ".ipynb"
nbname
)
).attr('target','_blank');
};
@ -231,10 +261,11 @@ var IPython = (function (IPython) {
NotebookList.prototype.add_name_input = function (nbname, item) {
item.data('nbname', nbname);
item.find(".item_icon").addClass('icon-book');
item.find(".item_name").empty().append(
$('<input/>')
.addClass("nbname_input")
.attr('value', nbname)
.attr('value', utils.splitext(nbname)[0])
.attr('size', '30')
.attr('type', 'text')
);
@ -248,7 +279,7 @@ var IPython = (function (IPython) {
NotebookList.prototype.add_shutdown_button = function (item, session) {
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) {
var settings = {
processData : false,
@ -303,7 +334,7 @@ var IPython = (function (IPython) {
notebooklist.baseProjectUrl(),
'api/notebooks',
notebooklist.notebookPath(),
nbname + '.ipynb'
nbname
);
$.ajax(url, settings);
}
@ -323,6 +354,9 @@ var IPython = (function (IPython) {
.addClass('btn btn-primary btn-mini upload_button')
.click(function (e) {
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 nbdata = item.data('nbdata');
var content_type = 'application/json';
@ -349,7 +383,7 @@ var IPython = (function (IPython) {
that.baseProjectUrl(),
'api/notebooks',
that.notebookPath(),
nbname + '.ipynb'
nbname
);
$.ajax(url, settings);
return false;

View File

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

View File

@ -5,21 +5,43 @@
* 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 {
padding: 5px;
height: 25px;
line-height: 25px;
padding: @dashboard_tb_pad 0 @dashboard_tb_pad 0;
}
.toolbar_info {
float: left;
}
.toolbar_buttons {
float: right;
.list_toolbar [class*="span"] {
min-height: @btn_small_height;
}
.list_header {
@ -27,8 +49,8 @@
}
.list_container {
margin-top: 16px;
margin-bottom: 16px;
margin-top: @dashboard_tb_pad;
margin-bottom: 5*@dashboard_tb_pad;
border: 1px solid @border_color;
border-radius: 4px;
}
@ -48,42 +70,55 @@
&:hover .list_item {
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 {
line-height: 24px;
line-height: @btn_mini_height;
height: @btn_small_height;
}
.list_container > div > span, .list_container > div > div {
padding: 8px;
.item_icon {
font-size: 14px;
color: @dark_dashboard_color;
margin-right: @dashboard_lr_pad;
}
.list_item a {
text-decoration: none;
.item_buttons {
line-height: 1em;
}
.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 {
}
.action_col {
}
input.nbname_input {
height: 15px;
input.engine_num_input {
width: 60px;
}
.highlight_text {
color: blue;
}
#project_name > .breadcrumb {
padding: 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" %}
{% block title %}IPython Dashboard{% endblock %}
{% block title %}{{page_title}}{% endblock %}
{% block stylesheet %}
@ -22,33 +22,38 @@ data-base-kernel-url="{{base_kernel_url}}"
<div id="ipython-main-app" class="container">
<div id="tabs" class="tabbable">
<ul class="nav nav-tabs" id="tabs">
<div id="tab_content" class="tabbable">
<ul id="tabs" class="nav nav-tabs">
<li class="active"><a href="#notebooks" data-toggle="tab">Notebooks</a></li>
<li><a href="#clusters" data-toggle="tab">Clusters</a></li>
</ul>
<div class="tab-content">
<div id="notebooks" class="tab-pane active">
<div id="notebook_toolbar">
<form id='alternate_upload' class='alternate_upload' >
<span id="drag_info" style="position:absolute" >
To import a notebook, drag the file onto the listing below or <strong>click here</strong>.
</span>
<input type="file" name="datafile" class="fileinput" multiple='multiple'>
</form>
<span id="notebook_buttons">
<button id="refresh_notebook_list" title="Refresh notebook list" class="btn btn-small">Refresh</button>
<button id="new_notebook" title="Create new notebook" class="btn btn-small">New Notebook</button>
</span>
<div id="notebook_toolbar" class="row-fluid">
<div class="span8">
<form id='alternate_upload' class='alternate_upload' >
<span id="drag_info" style="position:absolute" >
To import a notebook, drag the file onto the listing below or <strong>click here</strong>.
</span>
<input type="file" name="datafile" class="fileinput" multiple='multiple'>
</form>
</div>
<div class="span4 clearfix">
<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 id="notebook_list">
<div id="notebook_list_header" class="row-fluid list_header">
<div id="project_name">
<ul class="breadcrumb">
{% for component in tree_url_path.strip('/').split('/') %}
<li>{{component}} <span>/</span></li>
<li><a href="{{breadcrumbs[0][0]}}"><i class="icon-home"></i></a><span>/</span></li>
{% for crumb in breadcrumbs[1:] %}
<li><a href="{{crumb[0]}}">{{crumb[1]}}</a> <span>/</span></li>
{% endfor %}
</ul>
</div>
@ -58,20 +63,23 @@ data-base-kernel-url="{{base_kernel_url}}"
<div id="clusters" class="tab-pane">
<div id="cluster_toolbar">
<span id="cluster_list_info">IPython parallel computing clusters</span>
<span id="cluster_buttons">
<button id="refresh_cluster_list" title="Refresh cluster list" class="btn btn-small">Refresh</button>
</span>
<div id="cluster_toolbar" class="row-fluid">
<div class="span8">
<span id="cluster_list_info">IPython parallel computing clusters</span>
</div>
<div class="span4" class="clearfix">
<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 id="cluster_list">
<div id="cluster_list_header" class="row-fluid list_header">
<span class="profile_col span4">profile</span>
<span class="status_col span3">status</span>
<span class="engines_col span3" title="Enter the number of engines to start or empty for default"># of engines</span>
<span class="action_col span2">action</span>
<div class="profile_col span4">profile</div>
<div class="status_col span3">status</div>
<div class="engines_col span3" title="Enter the number of engines to start or empty for default"># of engines</div>
<div class="action_col span2">action</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.on('waitFor.timeout', function onWaitForTimeout(timeout) {
this.echo("Timeout for " + casper.get_notebook_server());

View File

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

View File

@ -42,13 +42,13 @@ class FilesTest(NotebookTestBase):
r.raise_for_status()
self.assertEqual(r.text, '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:
path = pjoin(nbdir, d.replace('/', os.sep))
for foo in ('foo', '.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):
"""pre-2.0 'files/' prefixed links are properly redirected"""

View File

@ -11,10 +11,13 @@
# Imports
#-----------------------------------------------------------------------------
import os
import nose.tools as nt
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
@ -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')
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 ..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
@ -29,6 +29,27 @@ from ..utils import url_path_join, path2url, url2path, url_escape
class TreeHandler(IPythonHandler):
"""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
def get(self, path='', name=None):
path = path.strip('/')
@ -41,13 +62,16 @@ class TreeHandler(IPythonHandler):
self.log.debug("Redirecting %s to %s", self.request.path, url)
self.redirect(url)
else:
if not nbm.path_exists(path=path):
# no such directory, 404
if not nbm.path_exists(path=path) or nbm.is_hidden(path):
# Directory is hidden or does not exist.
raise web.HTTPError(404)
breadcrumbs = self.generate_breadcrumbs(path)
page_title = self.generate_page_title(path)
self.write(self.render_template('tree.html',
project=self.project_dir,
tree_url_path=path,
page_title=page_title,
notebook_path=path,
breadcrumbs=breadcrumbs
))

View File

@ -12,7 +12,11 @@ Authors:
# the file COPYING, distributed as part of this software.
#-----------------------------------------------------------------------------
from __future__ import print_function
import os
import stat
try:
from urllib.parse import quote, unquote
except ImportError:
@ -20,6 +24,10 @@ except ImportError:
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
#-----------------------------------------------------------------------------
@ -72,3 +80,35 @@ def url_unescape(path):
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.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.env['IPYTHONDIR'] = self.ipydir.name
self.dirs.append(self.nbdir)
def launch(self):
# start the ipython notebook, so we get the port number
@ -191,7 +194,7 @@ class JSController(TestController):
def _init_server(self):
"Start the notebook server in a separate process"
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_port = q.get()
@ -202,18 +205,17 @@ class JSController(TestController):
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"""
import os
import IPython.html.notebookapp as nbapp
import sys
sys.stderr = open(os.devnull, 'w')
os.environ["IPYTHONDIR"] = nbdir
server = nbapp.NotebookApp()
args = ['--no-browser']
args.append('--notebook-dir='+nbdir)
args.append('--profile-dir='+nbdir)
args.append('--log-level='+str(loglevel))
args.extend(['--ipython-dir', ipydir])
args.extend(['--notebook-dir', nbdir])
args.extend(['--log-level', str(loglevel)])
server.initialize(args)
# communicate the port number to the parent process
q.put(server.port)